diff --git a/packages/console/core/migrations/20250902065410_fluffy_raza/migration.sql b/packages/console/core/migrations/20250902065410_fluffy_raza/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..8e817334645599397fbe27607039b1431ac754c0 --- /dev/null +++ b/packages/console/core/migrations/20250902065410_fluffy_raza/migration.sql @@ -0,0 +1,89 @@ +CREATE TABLE `account` ( + `id` varchar(30) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `email` varchar(255) NOT NULL, + CONSTRAINT `email` UNIQUE(`email`) +); +--> statement-breakpoint +CREATE TABLE `billing` ( + `id` varchar(30) NOT NULL, + `workspace_id` varchar(30) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `customer_id` varchar(255), + `payment_method_id` varchar(255), + `payment_method_last4` varchar(4), + `balance` bigint NOT NULL, + `reload` boolean, + CONSTRAINT `billing_workspace_id_id_pk` PRIMARY KEY(`workspace_id`,`id`) +); +--> statement-breakpoint +CREATE TABLE `payment` ( + `id` varchar(30) NOT NULL, + `workspace_id` varchar(30) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `customer_id` varchar(255), + `payment_id` varchar(255), + `amount` bigint NOT NULL, + CONSTRAINT `payment_workspace_id_id_pk` PRIMARY KEY(`workspace_id`,`id`) +); +--> statement-breakpoint +CREATE TABLE `usage` ( + `id` varchar(30) NOT NULL, + `workspace_id` varchar(30) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `model` varchar(255) NOT NULL, + `input_tokens` int NOT NULL, + `output_tokens` int NOT NULL, + `reasoning_tokens` int, + `cache_read_tokens` int, + `cache_write_tokens` int, + `cost` bigint NOT NULL, + CONSTRAINT `usage_workspace_id_id_pk` PRIMARY KEY(`workspace_id`,`id`) +); +--> statement-breakpoint +CREATE TABLE `key` ( + `id` varchar(30) NOT NULL, + `workspace_id` varchar(30) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `user_id` text NOT NULL, + `name` varchar(255) NOT NULL, + `key` varchar(255) NOT NULL, + `time_used` timestamp(3), + CONSTRAINT `key_workspace_id_id_pk` PRIMARY KEY(`workspace_id`,`id`), + CONSTRAINT `global_key` UNIQUE(`key`) +); +--> statement-breakpoint +CREATE TABLE `user` ( + `id` varchar(30) NOT NULL, + `workspace_id` varchar(30) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `email` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `time_seen` timestamp(3), + `color` int, + CONSTRAINT `user_workspace_id_id_pk` PRIMARY KEY(`workspace_id`,`id`), + CONSTRAINT `user_email` UNIQUE(`workspace_id`,`email`) +); +--> statement-breakpoint +CREATE TABLE `workspace` ( + `id` varchar(30) NOT NULL, + `slug` varchar(255), + `name` varchar(255), + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + CONSTRAINT `workspace_id` PRIMARY KEY(`id`), + CONSTRAINT `slug` UNIQUE(`slug`) +); diff --git a/packages/console/core/migrations/20250902065410_fluffy_raza/snapshot.json b/packages/console/core/migrations/20250902065410_fluffy_raza/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..6b26128c665835618fe674aee4c43415aaedaa85 --- /dev/null +++ b/packages/console/core/migrations/20250902065410_fluffy_raza/snapshot.json @@ -0,0 +1,967 @@ +{ + "version": "6", + "id": "aee779c5-db1d-4655-95ec-6451c18455be", + "prevIds": ["00000000-0000-0000-0000-000000000000"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20250903035359_serious_whistler/migration.sql b/packages/console/core/migrations/20250903035359_serious_whistler/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..4a9a03d6389ff5681a4655c864f2129c54962b5c --- /dev/null +++ b/packages/console/core/migrations/20250903035359_serious_whistler/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `key` ADD `actor` json;--> statement-breakpoint +ALTER TABLE `key` DROP COLUMN `user_id`; \ No newline at end of file diff --git a/packages/console/core/migrations/20250903035359_serious_whistler/snapshot.json b/packages/console/core/migrations/20250903035359_serious_whistler/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..8e0af76b5bf8d382b7d13655f46258119cae78ac --- /dev/null +++ b/packages/console/core/migrations/20250903035359_serious_whistler/snapshot.json @@ -0,0 +1,967 @@ +{ + "version": "6", + "id": "79b7ee25-1c1c-41ff-9bbf-754af257102b", + "prevIds": ["aee779c5-db1d-4655-95ec-6451c18455be"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20250911141957_dusty_clint_barton/migration.sql b/packages/console/core/migrations/20250911141957_dusty_clint_barton/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..14efae216e819909286335f80ab38db63d6507b1 --- /dev/null +++ b/packages/console/core/migrations/20250911141957_dusty_clint_barton/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `key` ADD CONSTRAINT `name` UNIQUE(`workspace_id`,`name`); \ No newline at end of file diff --git a/packages/console/core/migrations/20250911141957_dusty_clint_barton/snapshot.json b/packages/console/core/migrations/20250911141957_dusty_clint_barton/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..da412fed41a96d39077edac81d1f8e733c0a864c --- /dev/null +++ b/packages/console/core/migrations/20250911141957_dusty_clint_barton/snapshot.json @@ -0,0 +1,1001 @@ +{ + "version": "6", + "id": "26cebd59-f553-441c-a2b2-2f9578a0ad2b", + "prevIds": ["9f51ef52-31ac-4ace-8b6d-39b35efe9c81"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20250911231144_jazzy_skrulls/migration.sql b/packages/console/core/migrations/20250911231144_jazzy_skrulls/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..774c38dd8f53288a75e0ecf3a49518ad758eab23 --- /dev/null +++ b/packages/console/core/migrations/20250911231144_jazzy_skrulls/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `usage` MODIFY COLUMN `provider` varchar(255) NOT NULL; \ No newline at end of file diff --git a/packages/console/core/migrations/20250911231144_jazzy_skrulls/snapshot.json b/packages/console/core/migrations/20250911231144_jazzy_skrulls/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..57ef218deba68ca55d0fbfcf08bff3244d7c694b --- /dev/null +++ b/packages/console/core/migrations/20250911231144_jazzy_skrulls/snapshot.json @@ -0,0 +1,1015 @@ +{ + "version": "6", + "id": "d13af80e-3c70-4866-8f14-48e7ff6ff0ff", + "prevIds": ["06dc6226-bfbb-4ccc-b4bc-f26070c3bed5"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20250912021148_parallel_gauntlet/migration.sql b/packages/console/core/migrations/20250912021148_parallel_gauntlet/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..a1ff78e7832a23cd9b0afa36f5a33523bd7b9e44 --- /dev/null +++ b/packages/console/core/migrations/20250912021148_parallel_gauntlet/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `usage` ADD `cache_write_5m_tokens` int;--> statement-breakpoint +ALTER TABLE `usage` ADD `cache_write_1h_tokens` int; \ No newline at end of file diff --git a/packages/console/core/migrations/20250912021148_parallel_gauntlet/snapshot.json b/packages/console/core/migrations/20250912021148_parallel_gauntlet/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..75472504c3a8b5a7bce486ede3bb1f615c268d58 --- /dev/null +++ b/packages/console/core/migrations/20250912021148_parallel_gauntlet/snapshot.json @@ -0,0 +1,1043 @@ +{ + "version": "6", + "id": "b0ad4b11-b607-46c7-8e2d-3b9823cdc5f7", + "prevIds": ["d13af80e-3c70-4866-8f14-48e7ff6ff0ff"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20250912161749_familiar_nightshade/migration.sql b/packages/console/core/migrations/20250912161749_familiar_nightshade/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..89cf77f92f610497882d9c6bc0a09a4fd7aebc2e --- /dev/null +++ b/packages/console/core/migrations/20250912161749_familiar_nightshade/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `usage` DROP COLUMN `cache_write_tokens`; \ No newline at end of file diff --git a/packages/console/core/migrations/20250912161749_familiar_nightshade/snapshot.json b/packages/console/core/migrations/20250912161749_familiar_nightshade/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..5aa25052ab6a357e158ff040f658bc8e875f089b --- /dev/null +++ b/packages/console/core/migrations/20250912161749_familiar_nightshade/snapshot.json @@ -0,0 +1,1029 @@ +{ + "version": "6", + "id": "91067cc9-d492-47b3-932a-42dcc0920b3c", + "prevIds": ["b0ad4b11-b607-46c7-8e2d-3b9823cdc5f7"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20250914222302_redundant_piledriver/migration.sql b/packages/console/core/migrations/20250914222302_redundant_piledriver/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..4ba1b2d4a9eb60227c9f53caa84db408fed469df --- /dev/null +++ b/packages/console/core/migrations/20250914222302_redundant_piledriver/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `payment` ADD `error` varchar(255); \ No newline at end of file diff --git a/packages/console/core/migrations/20250914222302_redundant_piledriver/snapshot.json b/packages/console/core/migrations/20250914222302_redundant_piledriver/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..9f4163f61c3964f707cef1a5b33456dac59a1dba --- /dev/null +++ b/packages/console/core/migrations/20250914222302_redundant_piledriver/snapshot.json @@ -0,0 +1,1057 @@ +{ + "version": "6", + "id": "b0019e1e-d365-4f67-be3d-a2e69bdddc04", + "prevIds": ["3e080fc0-9efd-411f-b764-ed3aa4abcee5"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20250915150801_freezing_phil_sheldon/migration.sql b/packages/console/core/migrations/20250915150801_freezing_phil_sheldon/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..095f2599385470204ee5d0dd5637d71ca18d9d02 --- /dev/null +++ b/packages/console/core/migrations/20250915150801_freezing_phil_sheldon/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE `billing` ADD `last_error` varchar(255);--> statement-breakpoint +ALTER TABLE `billing` ADD `time_last_error` timestamp(3);--> statement-breakpoint +ALTER TABLE `payment` DROP COLUMN `error`; \ No newline at end of file diff --git a/packages/console/core/migrations/20250915150801_freezing_phil_sheldon/snapshot.json b/packages/console/core/migrations/20250915150801_freezing_phil_sheldon/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..e2f48178a845fbbb97c94c2538bf7fffb538edb3 --- /dev/null +++ b/packages/console/core/migrations/20250915150801_freezing_phil_sheldon/snapshot.json @@ -0,0 +1,1087 @@ +{ + "version": "6", + "id": "cd9c94c4-9167-4346-b716-1bd0cff10ffc", + "prevIds": ["1f08bd5a-436d-4905-a585-87b156847402"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "last_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_last_error", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20250915172258_absurd_hobgoblin/migration.sql b/packages/console/core/migrations/20250915172258_absurd_hobgoblin/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..8c331b12fc3e8d6a8feb906f818095b929019c58 --- /dev/null +++ b/packages/console/core/migrations/20250915172258_absurd_hobgoblin/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `billing` RENAME COLUMN `last_error` TO `reload_error`;--> statement-breakpoint +ALTER TABLE `billing` RENAME COLUMN `time_last_error` TO `time_reload_error`; \ No newline at end of file diff --git a/packages/console/core/migrations/20250915172258_absurd_hobgoblin/snapshot.json b/packages/console/core/migrations/20250915172258_absurd_hobgoblin/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..6c0a9b3836eaadd2e9b5cfda81611f615a9bae4f --- /dev/null +++ b/packages/console/core/migrations/20250915172258_absurd_hobgoblin/snapshot.json @@ -0,0 +1,1129 @@ +{ + "version": "6", + "id": "28336c91-553c-4d1d-9875-1ee761e47582", + "prevIds": ["ba801b30-747a-433e-ab43-b407c961a24c"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20250919135159_demonic_princess_powerful/migration.sql b/packages/console/core/migrations/20250919135159_demonic_princess_powerful/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..c2c228be1eef7dad49e20fc727ad79233613bc96 --- /dev/null +++ b/packages/console/core/migrations/20250919135159_demonic_princess_powerful/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `workspace` ADD `data_share` boolean; \ No newline at end of file diff --git a/packages/console/core/migrations/20250919135159_demonic_princess_powerful/snapshot.json b/packages/console/core/migrations/20250919135159_demonic_princess_powerful/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..c94526e91ab302e5143dcf42aed46f960678769b --- /dev/null +++ b/packages/console/core/migrations/20250919135159_demonic_princess_powerful/snapshot.json @@ -0,0 +1,1143 @@ +{ + "version": "6", + "id": "12189a4e-5083-4b17-b8e3-8279c9a3e61a", + "prevIds": ["28336c91-553c-4d1d-9875-1ee761e47582"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "data_share", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20250921042124_cloudy_revanche/migration.sql b/packages/console/core/migrations/20250921042124_cloudy_revanche/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..4a61a108daf0fc8ac0fd86de0e60da093eadc32a --- /dev/null +++ b/packages/console/core/migrations/20250921042124_cloudy_revanche/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `workspace` DROP COLUMN `data_share`; \ No newline at end of file diff --git a/packages/console/core/migrations/20250921042124_cloudy_revanche/snapshot.json b/packages/console/core/migrations/20250921042124_cloudy_revanche/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..9de034489b9c806660f4439aa3b5f80be5684498 --- /dev/null +++ b/packages/console/core/migrations/20250921042124_cloudy_revanche/snapshot.json @@ -0,0 +1,1129 @@ +{ + "version": "6", + "id": "7f3989cb-3e8b-430e-a0f5-f87051d1d824", + "prevIds": ["12189a4e-5083-4b17-b8e3-8279c9a3e61a"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20250923213126_cold_la_nuit/migration.sql b/packages/console/core/migrations/20250923213126_cold_la_nuit/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..860beb2e7050d8e7eb6668a279cb417714879c51 --- /dev/null +++ b/packages/console/core/migrations/20250923213126_cold_la_nuit/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `payment` ADD `time_refunded` timestamp(3); \ No newline at end of file diff --git a/packages/console/core/migrations/20250923213126_cold_la_nuit/snapshot.json b/packages/console/core/migrations/20250923213126_cold_la_nuit/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..8f513193ed67d00568245b48de79a79fa1c26230 --- /dev/null +++ b/packages/console/core/migrations/20250923213126_cold_la_nuit/snapshot.json @@ -0,0 +1,1143 @@ +{ + "version": "6", + "id": "45b67fb4-77ce-4aa2-b883-1971429c69f5", + "prevIds": ["7f3989cb-3e8b-430e-a0f5-f87051d1d824"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20250924230623_woozy_thaddeus_ross/migration.sql b/packages/console/core/migrations/20250924230623_woozy_thaddeus_ross/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..4aafaff697481fef90d139f987136d96eca4d76b --- /dev/null +++ b/packages/console/core/migrations/20250924230623_woozy_thaddeus_ross/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `payment` ADD `invoice_id` varchar(255); \ No newline at end of file diff --git a/packages/console/core/migrations/20250924230623_woozy_thaddeus_ross/snapshot.json b/packages/console/core/migrations/20250924230623_woozy_thaddeus_ross/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..4a6758c6be80ade6d83df4cfe2169dbc4c95c8ac --- /dev/null +++ b/packages/console/core/migrations/20250924230623_woozy_thaddeus_ross/snapshot.json @@ -0,0 +1,1157 @@ +{ + "version": "6", + "id": "100a21cf-ff9c-476f-bf7d-100c1824b2b2", + "prevIds": ["45b67fb4-77ce-4aa2-b883-1971429c69f5"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20250928235456_dazzling_cable/migration.sql b/packages/console/core/migrations/20250928235456_dazzling_cable/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..ab0a222389efde6894c64daadc26d3cebeb14604 --- /dev/null +++ b/packages/console/core/migrations/20250928235456_dazzling_cable/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `user` MODIFY COLUMN `role` enum('admin','member') NOT NULL; \ No newline at end of file diff --git a/packages/console/core/migrations/20250928235456_dazzling_cable/snapshot.json b/packages/console/core/migrations/20250928235456_dazzling_cable/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..4f52f4b0b5f711cd74fd264abcaccbacf68192b1 --- /dev/null +++ b/packages/console/core/migrations/20250928235456_dazzling_cable/snapshot.json @@ -0,0 +1,1185 @@ +{ + "version": "6", + "id": "a2bb7222-561c-45f0-8939-8ef9b8e57bb3", + "prevIds": ["e9c91c2d-787d-4234-b98d-1620e4ce80e1"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_joined", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20251002175032_nice_dreadnoughts/migration.sql b/packages/console/core/migrations/20251002175032_nice_dreadnoughts/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..60c7f8691cce24d3b814f3205d8e178de6d0705f --- /dev/null +++ b/packages/console/core/migrations/20251002175032_nice_dreadnoughts/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE `user` ADD `account_id` varchar(30);--> statement-breakpoint +ALTER TABLE `user` ADD `old_account_id` varchar(30);--> statement-breakpoint +ALTER TABLE `user` ADD CONSTRAINT `user_account_id` UNIQUE(`workspace_id`,`account_id`); \ No newline at end of file diff --git a/packages/console/core/migrations/20251002175032_nice_dreadnoughts/snapshot.json b/packages/console/core/migrations/20251002175032_nice_dreadnoughts/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..12dd96929f1771a44a443af7f23bd3afc4807607 --- /dev/null +++ b/packages/console/core/migrations/20251002175032_nice_dreadnoughts/snapshot.json @@ -0,0 +1,1233 @@ +{ + "version": "6", + "id": "2296e9e4-bee6-485b-a146-6666ac8dc0d0", + "prevIds": ["14616ba2-c21e-4787-a289-f2a3eb6de04f"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20251003202205_early_black_crow/migration.sql b/packages/console/core/migrations/20251003202205_early_black_crow/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..903c32bda165c7cb91c2e9df60e622edf10cd728 --- /dev/null +++ b/packages/console/core/migrations/20251003202205_early_black_crow/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user` DROP COLUMN `old_account_id`;--> statement-breakpoint +ALTER TABLE `user` DROP COLUMN `old_email`; \ No newline at end of file diff --git a/packages/console/core/migrations/20251003202205_early_black_crow/snapshot.json b/packages/console/core/migrations/20251003202205_early_black_crow/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..638be654c635f83ab26ba6f0d9ea22d98e97ff7f --- /dev/null +++ b/packages/console/core/migrations/20251003202205_early_black_crow/snapshot.json @@ -0,0 +1,1237 @@ +{ + "version": "6", + "id": "6d546f3e-17b2-4195-bb10-7e6d91774bd7", + "prevIds": ["6857f409-1b5d-4752-9d65-a82ee70e6ad2"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20251003210411_legal_joseph/migration.sql b/packages/console/core/migrations/20251003210411_legal_joseph/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..dcf4773debf094c6f0a519a0768f89de8825d774 --- /dev/null +++ b/packages/console/core/migrations/20251003210411_legal_joseph/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `key` ADD `user_id` varchar(30); \ No newline at end of file diff --git a/packages/console/core/migrations/20251003210411_legal_joseph/snapshot.json b/packages/console/core/migrations/20251003210411_legal_joseph/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..63663564462a4d49d53474f6697bd0caf777ae66 --- /dev/null +++ b/packages/console/core/migrations/20251003210411_legal_joseph/snapshot.json @@ -0,0 +1,1251 @@ +{ + "version": "6", + "id": "ce444765-0606-4880-970a-2176bc2a78ac", + "prevIds": ["6d546f3e-17b2-4195-bb10-7e6d91774bd7"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "name", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20251004030300_numerous_prodigy/migration.sql b/packages/console/core/migrations/20251004030300_numerous_prodigy/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..d90e747c1bf4c8f51d92d8f6a6beba490d6ff840 --- /dev/null +++ b/packages/console/core/migrations/20251004030300_numerous_prodigy/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `key` DROP INDEX `name`; \ No newline at end of file diff --git a/packages/console/core/migrations/20251004030300_numerous_prodigy/snapshot.json b/packages/console/core/migrations/20251004030300_numerous_prodigy/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..1588e6b8e3cee50d9b48c21d0b2c3a2b412cb112 --- /dev/null +++ b/packages/console/core/migrations/20251004030300_numerous_prodigy/snapshot.json @@ -0,0 +1,1231 @@ +{ + "version": "6", + "id": "9e1313c7-ca78-4d2c-b13b-625d9d6fcaa3", + "prevIds": ["ce444765-0606-4880-970a-2176bc2a78ac"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "actor", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "old_name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20251007024345_careful_cerise/migration.sql b/packages/console/core/migrations/20251007024345_careful_cerise/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..ba2bc02034715c06eab607a338e7b5627c57092d --- /dev/null +++ b/packages/console/core/migrations/20251007024345_careful_cerise/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `workspace` MODIFY COLUMN `name` varchar(255) NOT NULL; \ No newline at end of file diff --git a/packages/console/core/migrations/20251007024345_careful_cerise/snapshot.json b/packages/console/core/migrations/20251007024345_careful_cerise/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..7dd21cfe147267d1f9e11cb32b09944ff59d83d8 --- /dev/null +++ b/packages/console/core/migrations/20251007024345_careful_cerise/snapshot.json @@ -0,0 +1,1203 @@ +{ + "version": "6", + "id": "a331e38c-c2e3-406d-a1ff-b0af7229cd85", + "prevIds": ["05e873f6-1556-4bcb-8e19-14971e37610a"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20251008161718_outgoing_outlaw_kid/migration.sql b/packages/console/core/migrations/20251008161718_outgoing_outlaw_kid/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..c9f385e088684c2a4e10aa9311287ff215c859a7 --- /dev/null +++ b/packages/console/core/migrations/20251008161718_outgoing_outlaw_kid/migration.sql @@ -0,0 +1,11 @@ +CREATE TABLE `provider` ( + `id` varchar(30) NOT NULL, + `workspace_id` varchar(30) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `provider` varchar(64) NOT NULL, + `credentials` text NOT NULL, + CONSTRAINT `provider_workspace_id_id_pk` PRIMARY KEY(`workspace_id`,`id`), + CONSTRAINT `workspace_provider` UNIQUE(`workspace_id`,`provider`) +); diff --git a/packages/console/core/migrations/20251008161718_outgoing_outlaw_kid/snapshot.json b/packages/console/core/migrations/20251008161718_outgoing_outlaw_kid/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..d91bd7d1aa31a45226c83c065e370847b289320e --- /dev/null +++ b/packages/console/core/migrations/20251008161718_outgoing_outlaw_kid/snapshot.json @@ -0,0 +1,1487 @@ +{ + "version": "6", + "id": "9dceb591-8e08-4991-a49c-1f1741ec1e57", + "prevIds": ["eae45fcf-dc0f-4756-bc5d-30791f2965a2"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "account", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "model", + "entityType": "columns" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "provider", + "entityType": "columns" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "table": "provider", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "user", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "email", + "table": "account", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "table": "model", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "table": "provider", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20251017015733_narrow_blindfold/migration.sql b/packages/console/core/migrations/20251017015733_narrow_blindfold/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..c62f01f4e72d10d81a8b23a48af8193f1b5dcd84 --- /dev/null +++ b/packages/console/core/migrations/20251017015733_narrow_blindfold/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE `account` DROP INDEX `email`;--> statement-breakpoint +CREATE INDEX `account_id` ON `auth` (`account_id`);--> statement-breakpoint +ALTER TABLE `account` DROP COLUMN `email`; \ No newline at end of file diff --git a/packages/console/core/migrations/20251017015733_narrow_blindfold/snapshot.json b/packages/console/core/migrations/20251017015733_narrow_blindfold/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..026613145292143e65079c2aaef7005923f31526 --- /dev/null +++ b/packages/console/core/migrations/20251017015733_narrow_blindfold/snapshot.json @@ -0,0 +1,1623 @@ +{ + "version": "6", + "id": "10169105-4545-4894-838b-004c0a42c584", + "prevIds": ["34706440-26d7-43f5-9b39-815aa912e5ef"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "auth", + "entityType": "columns" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "auth", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "model", + "entityType": "columns" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "provider", + "entityType": "columns" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "table": "provider", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "user", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "table": "model", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "table": "provider", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20251017024232_slimy_energizer/migration.sql b/packages/console/core/migrations/20251017024232_slimy_energizer/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..a1662ce92d187132a4a931efeea7ba12326ea4c3 --- /dev/null +++ b/packages/console/core/migrations/20251017024232_slimy_energizer/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `account` ADD PRIMARY KEY(`id`);--> statement-breakpoint +ALTER TABLE `auth` ADD PRIMARY KEY(`id`); \ No newline at end of file diff --git a/packages/console/core/migrations/20251017024232_slimy_energizer/snapshot.json b/packages/console/core/migrations/20251017024232_slimy_energizer/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..106aa33c6dc4666836ce95796ff3f4caed390791 --- /dev/null +++ b/packages/console/core/migrations/20251017024232_slimy_energizer/snapshot.json @@ -0,0 +1,1635 @@ +{ + "version": "6", + "id": "5470c8b4-296d-47bd-85a7-88cfd3b71434", + "prevIds": ["10169105-4545-4894-838b-004c0a42c584"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "auth", + "entityType": "columns" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "auth", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "model", + "entityType": "columns" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "provider", + "entityType": "columns" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "table": "provider", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "user", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "table": "model", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "table": "provider", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20251031163113_messy_jackal/migration.sql b/packages/console/core/migrations/20251031163113_messy_jackal/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..28aa6909b533ac0d98e66d5300d15f5b5b577bd8 --- /dev/null +++ b/packages/console/core/migrations/20251031163113_messy_jackal/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `billing` ADD `reload_trigger` int;--> statement-breakpoint +ALTER TABLE `billing` ADD `reload_amount` int; \ No newline at end of file diff --git a/packages/console/core/migrations/20251031163113_messy_jackal/snapshot.json b/packages/console/core/migrations/20251031163113_messy_jackal/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..27f03383a643919334308691579aed3a1611263c --- /dev/null +++ b/packages/console/core/migrations/20251031163113_messy_jackal/snapshot.json @@ -0,0 +1,1663 @@ +{ + "version": "6", + "id": "8b7fa839-a088-408e-84a4-1a07325c0290", + "prevIds": ["5470c8b4-296d-47bd-85a7-88cfd3b71434"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "auth", + "entityType": "columns" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "auth", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "table": "usage", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "model", + "entityType": "columns" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "provider", + "entityType": "columns" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "table": "provider", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "user", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "table": "model", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "table": "provider", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20251125223403_famous_magik/migration.sql b/packages/console/core/migrations/20251125223403_famous_magik/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..ad195f555d0a0e730d08f9e153269b222cc13f1e --- /dev/null +++ b/packages/console/core/migrations/20251125223403_famous_magik/migration.sql @@ -0,0 +1,8 @@ +CREATE TABLE `ip` ( + `ip` varchar(45) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `usage` int, + CONSTRAINT `ip_ip_pk` PRIMARY KEY(`ip`) +); diff --git a/packages/console/core/migrations/20251125223403_famous_magik/snapshot.json b/packages/console/core/migrations/20251125223403_famous_magik/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..5250b414615a4c3e89af1ca7d98f79fec6b17557 --- /dev/null +++ b/packages/console/core/migrations/20251125223403_famous_magik/snapshot.json @@ -0,0 +1,1743 @@ +{ + "version": "6", + "id": "9d5d9885-7ec5-45f6-ac53-45a8e25dede7", + "prevIds": ["8b7fa839-a088-408e-84a4-1a07325c0290"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "auth", + "entityType": "columns" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "auth", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "table": "usage", + "entityType": "columns" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "ip", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "table": "ip", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "model", + "entityType": "columns" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "provider", + "entityType": "columns" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "table": "provider", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "user", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "table": "model", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "table": "provider", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260107041522_tiny_captain_midlands/migration.sql b/packages/console/core/migrations/20260107041522_tiny_captain_midlands/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..07163dd6524a0f908f47a2a0071a2592955a687c --- /dev/null +++ b/packages/console/core/migrations/20260107041522_tiny_captain_midlands/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `usage` RENAME COLUMN `data` TO `enrichment`; \ No newline at end of file diff --git a/packages/console/core/migrations/20260107041522_tiny_captain_midlands/snapshot.json b/packages/console/core/migrations/20260107041522_tiny_captain_midlands/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..088f95612d0f58e36f478fc7e098e38cb8d445e2 --- /dev/null +++ b/packages/console/core/migrations/20260107041522_tiny_captain_midlands/snapshot.json @@ -0,0 +1,2037 @@ +{ + "version": "6", + "id": "70394850-2c28-4012-a3d5-69357e3348b6", + "prevIds": ["3ff862f3-eeb6-4b10-8c78-254de3778ab3"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "auth", + "entityType": "columns" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "auth", + "entityType": "columns" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "table": "benchmark", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "table": "usage", + "entityType": "columns" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "ip", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "table": "ip", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "model", + "entityType": "columns" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "provider", + "entityType": "columns" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "table": "provider", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "user", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscribed", + "table": "user", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sub_interval_usage", + "table": "user", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sub_monthly_usage", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sub_time_interval_usage_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sub_time_monthly_usage_updated", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "table": "benchmark", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "table": "usage", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "table": "model", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "table": "provider", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260109001625_mean_frank_castle/migration.sql b/packages/console/core/migrations/20260109001625_mean_frank_castle/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..c2cf5ee47eaff71f231bd3d5c820ee310d083ff5 --- /dev/null +++ b/packages/console/core/migrations/20260109001625_mean_frank_castle/migration.sql @@ -0,0 +1,2 @@ +DROP INDEX `workspace_user_id` ON `subscription`;--> statement-breakpoint +ALTER TABLE `subscription` ADD CONSTRAINT `workspace_user_id` UNIQUE(`workspace_id`,`user_id`); \ No newline at end of file diff --git a/packages/console/core/migrations/20260109001625_mean_frank_castle/snapshot.json b/packages/console/core/migrations/20260109001625_mean_frank_castle/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..9a43cd2bdf6001cf0b3d160bce09b11b05507160 --- /dev/null +++ b/packages/console/core/migrations/20260109001625_mean_frank_castle/snapshot.json @@ -0,0 +1,2153 @@ +{ + "version": "6", + "id": "bb90bb3e-fd08-439a-b92f-5f433807480e", + "prevIds": ["fec4cb15-6f13-465d-a902-b76b026872f4"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "auth", + "entityType": "columns" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "auth", + "entityType": "columns" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "table": "benchmark", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "table": "usage", + "entityType": "columns" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "ip", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "table": "ip", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "model", + "entityType": "columns" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "provider", + "entityType": "columns" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "table": "provider", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "user", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "table": "benchmark", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "table": "subscription", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "table": "usage", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "table": "model", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "table": "provider", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260109014234_noisy_domino/migration.sql b/packages/console/core/migrations/20260109014234_noisy_domino/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..654a5a81db9404c8efe37f946addffc1f260fb69 --- /dev/null +++ b/packages/console/core/migrations/20260109014234_noisy_domino/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `billing` ADD `subscription_coupon_id` varchar(28); \ No newline at end of file diff --git a/packages/console/core/migrations/20260109014234_noisy_domino/snapshot.json b/packages/console/core/migrations/20260109014234_noisy_domino/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..0e6084f51e9ff8b2fd036737c66dfdd1e1af28cc --- /dev/null +++ b/packages/console/core/migrations/20260109014234_noisy_domino/snapshot.json @@ -0,0 +1,2167 @@ +{ + "version": "6", + "id": "2fd0308b-0653-437d-aea3-29428a4de9f1", + "prevIds": ["bb90bb3e-fd08-439a-b92f-5f433807480e"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "auth", + "entityType": "columns" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "auth", + "entityType": "columns" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "table": "benchmark", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_coupon_id", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "table": "usage", + "entityType": "columns" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "ip", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "table": "ip", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "model", + "entityType": "columns" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "provider", + "entityType": "columns" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "table": "provider", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "user", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "table": "benchmark", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "table": "subscription", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "table": "usage", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "table": "model", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "table": "provider", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260109040130_bumpy_mephistopheles/migration.sql b/packages/console/core/migrations/20260109040130_bumpy_mephistopheles/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..2c3d554f095e1f8e61a44ed0247da840cc631f8d --- /dev/null +++ b/packages/console/core/migrations/20260109040130_bumpy_mephistopheles/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `payment` ADD `enrichment` json; \ No newline at end of file diff --git a/packages/console/core/migrations/20260109040130_bumpy_mephistopheles/snapshot.json b/packages/console/core/migrations/20260109040130_bumpy_mephistopheles/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..ebff34d85d8d6da70e28d9ed1dc647f6f464d5b3 --- /dev/null +++ b/packages/console/core/migrations/20260109040130_bumpy_mephistopheles/snapshot.json @@ -0,0 +1,2181 @@ +{ + "version": "6", + "id": "a0d18802-c390-47d4-98f1-d1f83869cf0c", + "prevIds": ["2fd0308b-0653-437d-aea3-29428a4de9f1"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "auth", + "entityType": "columns" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "auth", + "entityType": "columns" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "table": "benchmark", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_coupon_id", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "table": "payment", + "entityType": "columns" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "table": "usage", + "entityType": "columns" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "ip", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "table": "ip", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "model", + "entityType": "columns" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "provider", + "entityType": "columns" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "table": "provider", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "user", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "table": "benchmark", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "table": "subscription", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "table": "usage", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "table": "model", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "table": "provider", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260116213606_gigantic_hardball/migration.sql b/packages/console/core/migrations/20260116213606_gigantic_hardball/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..72d43135f441a992b8265c779f2fcc84e53ecb8b --- /dev/null +++ b/packages/console/core/migrations/20260116213606_gigantic_hardball/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `billing` ADD `subscription` json; \ No newline at end of file diff --git a/packages/console/core/migrations/20260116213606_gigantic_hardball/snapshot.json b/packages/console/core/migrations/20260116213606_gigantic_hardball/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..d62a4ca5820cccf4761a80c34762a249da1d47e5 --- /dev/null +++ b/packages/console/core/migrations/20260116213606_gigantic_hardball/snapshot.json @@ -0,0 +1,2223 @@ +{ + "version": "6", + "id": "32a0c40b-a269-4ad1-a5a0-52b1f18932aa", + "prevIds": ["00774acd-a1e5-49c0-b296-cacc9506a566"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "auth", + "entityType": "columns" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "auth", + "entityType": "columns" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "table": "benchmark", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_coupon_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "table": "payment", + "entityType": "columns" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "table": "usage", + "entityType": "columns" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "ip", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "table": "ip", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "model", + "entityType": "columns" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "provider", + "entityType": "columns" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "table": "provider", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "user", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "table": "benchmark", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "table": "subscription", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "table": "usage", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "table": "model", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "table": "provider", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260116224745_numerous_annihilus/migration.sql b/packages/console/core/migrations/20260116224745_numerous_annihilus/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..299847db64f8aed87c3490a900bdd81576d6ee4b --- /dev/null +++ b/packages/console/core/migrations/20260116224745_numerous_annihilus/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `billing` DROP COLUMN `subscription_coupon_id`; \ No newline at end of file diff --git a/packages/console/core/migrations/20260116224745_numerous_annihilus/snapshot.json b/packages/console/core/migrations/20260116224745_numerous_annihilus/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..85f6c848a9bb05915a6bb2c99592cb92c6bcf0b5 --- /dev/null +++ b/packages/console/core/migrations/20260116224745_numerous_annihilus/snapshot.json @@ -0,0 +1,2209 @@ +{ + "version": "6", + "id": "a0ade64b-b735-4a70-8d39-ebd84bc9e924", + "prevIds": ["32a0c40b-a269-4ad1-a5a0-52b1f18932aa"], + "dialect": "mysql", + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "account", + "entityType": "columns" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "auth", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "auth", + "entityType": "columns" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "table": "auth", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "auth", + "entityType": "columns" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "table": "benchmark", + "entityType": "columns" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "table": "benchmark", + "entityType": "columns" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "billing", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "billing", + "entityType": "columns" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "table": "billing", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "table": "billing", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "table": "billing", + "entityType": "columns" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "table": "billing", + "entityType": "columns" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "table": "billing", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "table": "billing", + "entityType": "columns" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "table": "payment", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "table": "payment", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "table": "payment", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "table": "payment", + "entityType": "columns" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "table": "subscription", + "entityType": "columns" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "usage", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "table": "usage", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "table": "usage", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "table": "usage", + "entityType": "columns" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "table": "usage", + "entityType": "columns" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "table": "ip_rate_limit", + "entityType": "columns" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "ip", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "ip", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "table": "ip", + "entityType": "columns" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "table": "key", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "table": "key", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "table": "key", + "entityType": "columns" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "model", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "model", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "table": "model", + "entityType": "columns" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "provider", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "provider", + "entityType": "columns" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "table": "provider", + "entityType": "columns" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "table": "provider", + "entityType": "columns" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "table": "user", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "table": "user", + "entityType": "columns" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "table": "user", + "entityType": "columns" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "table": "user", + "entityType": "columns" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "table": "user", + "entityType": "columns" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "table": "workspace", + "entityType": "columns" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "table": "workspace", + "entityType": "columns" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "table": "auth", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "table": "benchmark", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "table": "billing", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "table": "subscription", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "table": "usage", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "table": "key", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "table": "model", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "table": "provider", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "table": "user", + "entityType": "indexes" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "table": "user", + "entityType": "indexes" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "table": "workspace", + "entityType": "indexes" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260414235536_lame_wild_child/migration.sql b/packages/console/core/migrations/20260414235536_lame_wild_child/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..439bf0c671b01c72a3eb4a49e4305853827e27b2 --- /dev/null +++ b/packages/console/core/migrations/20260414235536_lame_wild_child/migration.sql @@ -0,0 +1,6 @@ +CREATE TABLE `key_rate_limit` ( + `key` varchar(255) NOT NULL, + `interval` varchar(12) NOT NULL, + `count` int NOT NULL, + CONSTRAINT PRIMARY KEY(`key`,`interval`) +); diff --git a/packages/console/core/migrations/20260414235536_lame_wild_child/snapshot.json b/packages/console/core/migrations/20260414235536_lame_wild_child/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..541904291d8b84c7b54639a7ad0d04ce3b56cfde --- /dev/null +++ b/packages/console/core/migrations/20260414235536_lame_wild_child/snapshot.json @@ -0,0 +1,2515 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "09166f7c-37b3-456a-93a9-76a3a29a7b5e", + "prevIds": ["5e506dec-61e7-4726-81d1-afa4ffbc61ed"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(12)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260417071612_tidy_diamondback/migration.sql b/packages/console/core/migrations/20260417071612_tidy_diamondback/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..41a4efe68e1f5360d4295869a01a93aece92a55e --- /dev/null +++ b/packages/console/core/migrations/20260417071612_tidy_diamondback/migration.sql @@ -0,0 +1,6 @@ +CREATE TABLE `model_rate_limit` ( + `key` varchar(255) NOT NULL, + `interval` varchar(40) NOT NULL, + `count` int NOT NULL, + CONSTRAINT PRIMARY KEY(`key`,`interval`) +); diff --git a/packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json b/packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..2152bfa76fe07e2ec6111f56771bc586ae560b91 --- /dev/null +++ b/packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json @@ -0,0 +1,2567 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "93c492af-c95b-4213-9fc2-38c3dd10374d", + "prevIds": ["a09a925d-6cdd-4e7c-b8b1-11c259928b4c"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "model_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260420185813_supreme_roxanne_simpson/migration.sql b/packages/console/core/migrations/20260420185813_supreme_roxanne_simpson/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..00b7b5fd047c8d6cecd1ccd2073a82fa46bad3d2 --- /dev/null +++ b/packages/console/core/migrations/20260420185813_supreme_roxanne_simpson/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE `model_tpm_limit` DROP PRIMARY KEY;--> statement-breakpoint +ALTER TABLE `model_tpm_limit` ADD PRIMARY KEY (`id`);--> statement-breakpoint +ALTER TABLE `model_tpm_limit` DROP COLUMN `interval`; \ No newline at end of file diff --git a/packages/console/core/migrations/20260420185813_supreme_roxanne_simpson/snapshot.json b/packages/console/core/migrations/20260420185813_supreme_roxanne_simpson/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..7549846f4cd062241fbaf8cbc7c9ab5ae360c1cd --- /dev/null +++ b/packages/console/core/migrations/20260420185813_supreme_roxanne_simpson/snapshot.json @@ -0,0 +1,2657 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "1f9a146c-a20b-4246-a78d-3a261e91fc41", + "prevIds": ["1bd00ab4-28b3-4852-b95f-f854e4195a77"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "coupon", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tpm_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "enum('BUILDATHON','GOFREEMONTH')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_redeemed", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tpm_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_tpm_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["email", "type"], + "name": "PRIMARY", + "table": "coupon", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "model_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_tpm_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260427053132_smiling_puppet_master/migration.sql b/packages/console/core/migrations/20260427053132_smiling_puppet_master/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..6d1543011720e7c25ac9bb712d8397033b78a55f --- /dev/null +++ b/packages/console/core/migrations/20260427053132_smiling_puppet_master/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `coupon` MODIFY COLUMN `type` enum('BUILDATHON','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100') NOT NULL; \ No newline at end of file diff --git a/packages/console/core/migrations/20260427053132_smiling_puppet_master/snapshot.json b/packages/console/core/migrations/20260427053132_smiling_puppet_master/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..5f5e5bc1aaae5068c9633505628c2e5c05dd0048 --- /dev/null +++ b/packages/console/core/migrations/20260427053132_smiling_puppet_master/snapshot.json @@ -0,0 +1,2619 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "b3b243c0-8097-4d8a-a439-243d5a7d543f", + "prevIds": ["9e2d81ba-88b4-4704-a8b6-4a27dd2bd8d9"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "coupon", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tpm_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "enum('BUILDATHON','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_redeemed", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["email", "type"], + "name": "PRIMARY", + "table": "coupon", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tpm_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260513173355_groovy_jane_foster/migration.sql b/packages/console/core/migrations/20260513173355_groovy_jane_foster/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..76f85ee168d55fef10ffbc0ae3a87400eaf4862b --- /dev/null +++ b/packages/console/core/migrations/20260513173355_groovy_jane_foster/migration.sql @@ -0,0 +1,47 @@ +CREATE TABLE `referral_code` ( + `id` varchar(30) NOT NULL, + `workspace_id` varchar(30) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `code` varchar(10) NOT NULL, + CONSTRAINT PRIMARY KEY(`workspace_id`,`id`), + CONSTRAINT `referral_code_workspace_id` UNIQUE INDEX(`workspace_id`), + CONSTRAINT `referral_code_code` UNIQUE INDEX(`code`) +); +--> statement-breakpoint +CREATE TABLE `referral_reward` ( + `id` varchar(30) NOT NULL, + `workspace_id` varchar(30) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `referral_id` varchar(30) NOT NULL, + `source` enum('inviter','invitee') NOT NULL, + `amount` bigint NOT NULL, + `applied_by_user_id` varchar(30), + `time_applied` timestamp(3), + CONSTRAINT PRIMARY KEY(`workspace_id`,`id`), + CONSTRAINT `referral_reward_referral_source` UNIQUE INDEX(`referral_id`,`source`) +); +--> statement-breakpoint +CREATE TABLE `referral` ( + `id` varchar(30) NOT NULL, + `workspace_id` varchar(30) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `inviter_workspace_id` varchar(30) NOT NULL, + `invitee_account_id` varchar(30) NOT NULL, + `invitee_user_id` varchar(30) NOT NULL, + `referral_code_id` varchar(30) NOT NULL, + `stripe_customer_id` varchar(255) NOT NULL, + `stripe_subscription_id` varchar(255) NOT NULL, + CONSTRAINT PRIMARY KEY(`workspace_id`,`id`), + CONSTRAINT `referral_invitee_account_id` UNIQUE INDEX(`invitee_account_id`), + CONSTRAINT `referral_stripe_subscription_id` UNIQUE INDEX(`stripe_subscription_id`) +); +--> statement-breakpoint +CREATE INDEX `referral_reward_workspace_time` ON `referral_reward` (`workspace_id`,`time_created`);--> statement-breakpoint +CREATE INDEX `referral_inviter_workspace_id` ON `referral` (`inviter_workspace_id`);--> statement-breakpoint +CREATE INDEX `referral_code_id` ON `referral` (`referral_code_id`); diff --git a/packages/console/core/migrations/20260513173355_groovy_jane_foster/snapshot.json b/packages/console/core/migrations/20260513173355_groovy_jane_foster/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..3fb58d1fb16321fb48fca2f94f3652bfe4843c7f --- /dev/null +++ b/packages/console/core/migrations/20260513173355_groovy_jane_foster/snapshot.json @@ -0,0 +1,3229 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "91a9afa8-a5b1-4fa4-b71d-b3ae866744c9", + "prevIds": ["c742e0f2-5d89-4216-b843-059d00680f13"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "coupon", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tpm_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tps_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "referral_code", + "entityType": "tables" + }, + { + "name": "referral_reward", + "entityType": "tables" + }, + { + "name": "referral", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "enum('BUILDATHON','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_redeemed", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "qualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "unqualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "code", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "referral_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "enum('inviter','invitee')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "applied_by_user_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_applied", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "inviter_workspace_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invitee_account_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invitee_user_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "referral_code_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "stripe_customer_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "stripe_subscription_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["email", "type"], + "name": "PRIMARY", + "table": "coupon", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tpm_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tps_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "referral_code", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "referral_reward", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "referral", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_code_workspace_id", + "entityType": "indexes", + "table": "referral_code" + }, + { + "columns": [ + { + "value": "code", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_code_code", + "entityType": "indexes", + "table": "referral_code" + }, + { + "columns": [ + { + "value": "referral_id", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_reward_referral_source", + "entityType": "indexes", + "table": "referral_reward" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_reward_workspace_time", + "entityType": "indexes", + "table": "referral_reward" + }, + { + "columns": [ + { + "value": "invitee_account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_invitee_account_id", + "entityType": "indexes", + "table": "referral" + }, + { + "columns": [ + { + "value": "stripe_subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_stripe_subscription_id", + "entityType": "indexes", + "table": "referral" + }, + { + "columns": [ + { + "value": "inviter_workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_inviter_workspace_id", + "entityType": "indexes", + "table": "referral" + }, + { + "columns": [ + { + "value": "referral_code_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_code_id", + "entityType": "indexes", + "table": "referral" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260516082200_long_spirit/migration.sql b/packages/console/core/migrations/20260516082200_long_spirit/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..471552c8b0ecd588e4d24492396055e4167d4ed6 --- /dev/null +++ b/packages/console/core/migrations/20260516082200_long_spirit/migration.sql @@ -0,0 +1,20 @@ +DROP TABLE `referral_code`;--> statement-breakpoint +DROP INDEX `referral_reward_referral_source` ON `referral_reward`;--> statement-breakpoint +DROP INDEX `referral_stripe_subscription_id` ON `referral`;--> statement-breakpoint +DROP INDEX `referral_inviter_workspace_id` ON `referral`;--> statement-breakpoint +DROP INDEX `referral_code_id` ON `referral`;--> statement-breakpoint +ALTER TABLE `referral_reward` DROP PRIMARY KEY;--> statement-breakpoint +ALTER TABLE `referral` DROP PRIMARY KEY;--> statement-breakpoint +ALTER TABLE `referral_reward` MODIFY COLUMN `workspace_id` varchar(30);--> statement-breakpoint +ALTER TABLE `workspace` ADD `referral_code` varchar(16);--> statement-breakpoint +ALTER TABLE `referral_reward` ADD PRIMARY KEY (`id`);--> statement-breakpoint +ALTER TABLE `referral` ADD PRIMARY KEY (`id`);--> statement-breakpoint +CREATE INDEX `referral_workspace_id` ON `referral` (`workspace_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `workspace_referral_code` ON `workspace` (`referral_code`);--> statement-breakpoint +ALTER TABLE `referral_reward` DROP COLUMN `source`;--> statement-breakpoint +ALTER TABLE `referral_reward` DROP COLUMN `applied_by_user_id`;--> statement-breakpoint +ALTER TABLE `referral` DROP COLUMN `inviter_workspace_id`;--> statement-breakpoint +ALTER TABLE `referral` DROP COLUMN `invitee_user_id`;--> statement-breakpoint +ALTER TABLE `referral` DROP COLUMN `referral_code_id`;--> statement-breakpoint +ALTER TABLE `referral` DROP COLUMN `stripe_customer_id`;--> statement-breakpoint +ALTER TABLE `referral` DROP COLUMN `stripe_subscription_id`; \ No newline at end of file diff --git a/packages/console/core/migrations/20260516082200_long_spirit/snapshot.json b/packages/console/core/migrations/20260516082200_long_spirit/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..2bdb0af0e7898eba9f11d5541b1166a8590e8ab0 --- /dev/null +++ b/packages/console/core/migrations/20260516082200_long_spirit/snapshot.json @@ -0,0 +1,3063 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "e93db4f6-6e8b-43f4-b883-59d2fd6ede53", + "prevIds": ["1f04bd59-35b0-493b-9d55-cfa08207ba8e", "91a9afa8-a5b1-4fa4-b71d-b3ae866744c9"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "coupon", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_sticky_provider", + "entityType": "tables" + }, + { + "name": "model_tpm_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tps_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "referral_reward", + "entityType": "tables" + }, + { + "name": "referral", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "enum('BUILDATHON','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_redeemed", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "qualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "unqualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "referral_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_applied", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invitee_account_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(16)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "referral_code", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["email", "type"], + "name": "PRIMARY", + "table": "coupon", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_sticky_provider", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tpm_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tps_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "referral_reward", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "referral", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_reward_workspace_time", + "entityType": "indexes", + "table": "referral_reward" + }, + { + "columns": [ + { + "value": "invitee_account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_invitee_account_id", + "entityType": "indexes", + "table": "referral" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_workspace_id", + "entityType": "indexes", + "table": "referral" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + }, + { + "columns": [ + { + "value": "referral_code", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_referral_code", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260516110447_classy_wilson_fisk/migration.sql b/packages/console/core/migrations/20260516110447_classy_wilson_fisk/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..ab3a10784745a4edb8364af66213056787ed412f --- /dev/null +++ b/packages/console/core/migrations/20260516110447_classy_wilson_fisk/migration.sql @@ -0,0 +1,4 @@ +DROP INDEX `referral_reward_workspace_time` ON `referral_reward`;--> statement-breakpoint +ALTER TABLE `referral_reward` DROP PRIMARY KEY;--> statement-breakpoint +ALTER TABLE `referral_reward` ADD PRIMARY KEY (`workspace_id`,`referral_id`);--> statement-breakpoint +ALTER TABLE `referral_reward` DROP COLUMN `id`; \ No newline at end of file diff --git a/packages/console/core/migrations/20260516110447_classy_wilson_fisk/snapshot.json b/packages/console/core/migrations/20260516110447_classy_wilson_fisk/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..f22931251840a9c03a326d3a9380049bdefd3405 --- /dev/null +++ b/packages/console/core/migrations/20260516110447_classy_wilson_fisk/snapshot.json @@ -0,0 +1,3029 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "77de1eaf-69f6-433f-b244-cda75319450a", + "prevIds": ["e93db4f6-6e8b-43f4-b883-59d2fd6ede53"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "coupon", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_sticky_provider", + "entityType": "tables" + }, + { + "name": "model_tpm_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tps_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "referral_reward", + "entityType": "tables" + }, + { + "name": "referral", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "enum('BUILDATHON','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_redeemed", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "qualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "unqualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "referral_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_applied", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invitee_account_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(16)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "referral_code", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["email", "type"], + "name": "PRIMARY", + "table": "coupon", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_sticky_provider", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tpm_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tps_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "referral_id"], + "name": "PRIMARY", + "table": "referral_reward", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "referral", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "invitee_account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_invitee_account_id", + "entityType": "indexes", + "table": "referral" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_workspace_id", + "entityType": "indexes", + "table": "referral" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + }, + { + "columns": [ + { + "value": "referral_code", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_referral_code", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260517075207_famous_chat/migration.sql b/packages/console/core/migrations/20260517075207_famous_chat/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..33ca1329f68b1dbd6ae984ac66c6c44cdcc9ff14 --- /dev/null +++ b/packages/console/core/migrations/20260517075207_famous_chat/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `coupon` MODIFY COLUMN `type` enum('BUILDATHON','GO1MONTH50','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100') NOT NULL; \ No newline at end of file diff --git a/packages/console/core/migrations/20260517075207_famous_chat/snapshot.json b/packages/console/core/migrations/20260517075207_famous_chat/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..b2ba1132fca3b4d0d62f38eb127917dc02f250e6 --- /dev/null +++ b/packages/console/core/migrations/20260517075207_famous_chat/snapshot.json @@ -0,0 +1,2765 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "dc498f8b-c8e7-4da5-b70a-419427dd5901", + "prevIds": ["1f04bd59-35b0-493b-9d55-cfa08207ba8e"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "coupon", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_sticky_provider", + "entityType": "tables" + }, + { + "name": "model_tpm_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tps_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "enum('BUILDATHON','GO1MONTH50','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_redeemed", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "qualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "unqualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["email", "type"], + "name": "PRIMARY", + "table": "coupon", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_sticky_provider", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tpm_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tps_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260520164350_quick_red_hulk/migration.sql b/packages/console/core/migrations/20260520164350_quick_red_hulk/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..72102d74a8e68bb4f326fc250ca28d478556752d --- /dev/null +++ b/packages/console/core/migrations/20260520164350_quick_red_hulk/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE `referral_code` ( + `workspace_id` varchar(30) PRIMARY KEY, + `code` varchar(10) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + CONSTRAINT `code` UNIQUE INDEX(`code`) +); +--> statement-breakpoint +DROP INDEX `referral_invitee_account_id` ON `referral`;--> statement-breakpoint +DROP INDEX `referral_code` ON `workspace`;--> statement-breakpoint +CREATE UNIQUE INDEX `invitee_account_id` ON `referral` (`invitee_account_id`);--> statement-breakpoint +ALTER TABLE `workspace` DROP COLUMN `referral_code`; diff --git a/packages/console/core/migrations/20260520164350_quick_red_hulk/snapshot.json b/packages/console/core/migrations/20260520164350_quick_red_hulk/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..40f2b32313865872f2558747a78735e20a8e98ec --- /dev/null +++ b/packages/console/core/migrations/20260520164350_quick_red_hulk/snapshot.json @@ -0,0 +1,3079 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "a946ea14-a35d-49cc-a10a-8c93c30a3cb1", + "prevIds": ["9a7123cb-5c2c-47f6-b22f-8fd6a02dcb03", "dc498f8b-c8e7-4da5-b70a-419427dd5901"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "coupon", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_sticky_provider", + "entityType": "tables" + }, + { + "name": "model_tpm_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tps_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "referral_code", + "entityType": "tables" + }, + { + "name": "referral_reward", + "entityType": "tables" + }, + { + "name": "referral", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "enum('BUILDATHON','GO1MONTH50','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_redeemed", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "qualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "unqualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "code", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "referral_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_applied", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invitee_account_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["email", "type"], + "name": "PRIMARY", + "table": "coupon", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_sticky_provider", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tpm_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tps_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id"], + "name": "PRIMARY", + "table": "referral_code", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "referral_id"], + "name": "PRIMARY", + "table": "referral_reward", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "referral", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "code", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "code", + "entityType": "indexes", + "table": "referral_code" + }, + { + "columns": [ + { + "value": "invitee_account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "invitee_account_id", + "entityType": "indexes", + "table": "referral" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260626161712_oval_callisto/migration.sql b/packages/console/core/migrations/20260626161712_oval_callisto/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..ebef63c964040060e4b62b2a799fe79c62116813 --- /dev/null +++ b/packages/console/core/migrations/20260626161712_oval_callisto/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `workspace` ADD `region` json; \ No newline at end of file diff --git a/packages/console/core/migrations/20260626161712_oval_callisto/snapshot.json b/packages/console/core/migrations/20260626161712_oval_callisto/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..ac6ab735ae7ac63acf4e02efde32dcf4bcc11c68 --- /dev/null +++ b/packages/console/core/migrations/20260626161712_oval_callisto/snapshot.json @@ -0,0 +1,3093 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "d78d7057-b247-4572-9150-2532149169db", + "prevIds": ["a946ea14-a35d-49cc-a10a-8c93c30a3cb1"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "coupon", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_sticky_provider", + "entityType": "tables" + }, + { + "name": "model_tpm_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tps_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "referral_code", + "entityType": "tables" + }, + { + "name": "referral_reward", + "entityType": "tables" + }, + { + "name": "referral", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "enum('BUILDATHON','GO1MONTH50','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_redeemed", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "qualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "unqualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "code", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "referral_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_applied", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invitee_account_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "region", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["email", "type"], + "name": "PRIMARY", + "table": "coupon", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_sticky_provider", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tpm_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tps_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id"], + "name": "PRIMARY", + "table": "referral_code", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "referral_id"], + "name": "PRIMARY", + "table": "referral_reward", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "referral", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "code", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "code", + "entityType": "indexes", + "table": "referral_code" + }, + { + "columns": [ + { + "value": "invitee_account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "invitee_account_id", + "entityType": "indexes", + "table": "referral" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260819012908_flashy_arclight/migration.sql b/packages/console/core/migrations/20260819012908_flashy_arclight/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..c7741dddaaeda17d5805d45df7c159d44c117767 --- /dev/null +++ b/packages/console/core/migrations/20260819012908_flashy_arclight/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `workspace` ADD `allow_non_zdr` boolean; diff --git a/packages/console/core/migrations/20260819012908_flashy_arclight/snapshot.json b/packages/console/core/migrations/20260819012908_flashy_arclight/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..e2ae9098d1af2ef7092afc3b781a9b72b80dd5cc --- /dev/null +++ b/packages/console/core/migrations/20260819012908_flashy_arclight/snapshot.json @@ -0,0 +1,3181 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "39a41b57-a092-4356-a8ed-2a51cde2da1d", + "prevIds": ["2752a0ba-95b5-492a-83fd-3dfe7fa77734"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "coupon", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_sticky_provider", + "entityType": "tables" + }, + { + "name": "model_tpm_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tps_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "referral_code", + "entityType": "tables" + }, + { + "name": "referral_reward", + "entityType": "tables" + }, + { + "name": "referral", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "enum('BUILDATHON','GO1MONTH50','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_redeemed", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "qualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "unqualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "code", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "referral_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_applied", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invitee_account_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "region", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "allow_non_zdr", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "is_blocked", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "is_flagged_by_anthropic", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "is_flagged_by_openai", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["email", "type"], + "name": "PRIMARY", + "table": "coupon", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_sticky_provider", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tpm_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tps_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id"], + "name": "PRIMARY", + "table": "referral_code", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "referral_id"], + "name": "PRIMARY", + "table": "referral_reward", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "referral", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "lite_subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_lite_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "code", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "code", + "entityType": "indexes", + "table": "referral_code" + }, + { + "columns": [ + { + "value": "referral_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_id", + "entityType": "indexes", + "table": "referral_reward" + }, + { + "columns": [ + { + "value": "invitee_account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "invitee_account_id", + "entityType": "indexes", + "table": "referral" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/console/core/migrations/20260901161032_workspace_migrated_at/migration.sql b/packages/console/core/migrations/20260901161032_workspace_migrated_at/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..87918c7305f5aab7696392caa7d56f09b3f861c7 --- /dev/null +++ b/packages/console/core/migrations/20260901161032_workspace_migrated_at/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `workspace` ADD `migrated_at` timestamp(3); diff --git a/packages/console/core/migrations/20260901161032_workspace_migrated_at/snapshot.json b/packages/console/core/migrations/20260901161032_workspace_migrated_at/snapshot.json new file mode 100644 index 0000000000000000000000000000000000000000..e81037d44227abfb9f2fc039cf06c91ab8e0a7bb --- /dev/null +++ b/packages/console/core/migrations/20260901161032_workspace_migrated_at/snapshot.json @@ -0,0 +1,3195 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "3e952a24-4137-4944-b7fa-e39e71061235", + "prevIds": ["e365c1d7-fb02-44ad-b681-87bb51d01964"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "coupon", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_sticky_provider", + "entityType": "tables" + }, + { + "name": "model_tpm_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tps_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "referral_code", + "entityType": "tables" + }, + { + "name": "referral_reward", + "entityType": "tables" + }, + { + "name": "referral", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "enum('BUILDATHON','GO1MONTH50','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_redeemed", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "qualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "unqualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "code", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "referral_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_applied", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invitee_account_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "region", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "allow_training", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "is_blocked", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "is_flagged_by_anthropic", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "is_flagged_by_openai", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "migrated_at", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["email", "type"], + "name": "PRIMARY", + "table": "coupon", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "model_sticky_provider", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tpm_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tps_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id"], + "name": "PRIMARY", + "table": "referral_code", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "referral_id"], + "name": "PRIMARY", + "table": "referral_reward", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "referral", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "lite_subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_lite_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "code", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "code", + "entityType": "indexes", + "table": "referral_code" + }, + { + "columns": [ + { + "value": "referral_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "referral_id", + "entityType": "indexes", + "table": "referral_reward" + }, + { + "columns": [ + { + "value": "invitee_account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "invitee_account_id", + "entityType": "indexes", + "table": "referral" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode new file mode 100644 index 0000000000000000000000000000000000000000..a7101f42b0fef1d853f05d1770f91258450d5ee9 --- /dev/null +++ b/packages/opencode/bin/opencode @@ -0,0 +1,199 @@ +#!/usr/bin/env node + +const childProcess = require("child_process") +const fs = require("fs") +const path = require("path") +const os = require("os") + +const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"] + +function run(target) { + const child = childProcess.spawn(target, process.argv.slice(2), { + stdio: "inherit", + }) + + child.on("error", (error) => { + console.error(error.message) + process.exit(1) + }) + + const forwarders = {} + for (const signal of forwardedSignals) { + forwarders[signal] = () => { + try { + child.kill(signal) + } catch { + // The child may have already exited. + } + } + process.on(signal, forwarders[signal]) + } + + child.on("exit", (code, signal) => { + for (const forwardedSignal of forwardedSignals) { + process.removeListener(forwardedSignal, forwarders[forwardedSignal]) + } + + if (signal) { + process.kill(process.pid, signal) + return + } + + process.exit(typeof code === "number" ? code : 0) + }) +} + +const envPath = process.env.OPENCODE_BIN_PATH + +const scriptPath = fs.realpathSync(__filename) +const scriptDir = path.dirname(scriptPath) + +// +const cached = path.join(scriptDir, ".opencode") + +const platformMap = { + darwin: "darwin", + linux: "linux", + win32: "windows", +} +const archMap = { + x64: "x64", + arm64: "arm64", + arm: "arm", +} + +let platform = platformMap[os.platform()] +if (!platform) { + platform = os.platform() +} +let arch = archMap[os.arch()] +if (!arch) { + arch = os.arch() +} +const base = "opencode-" + platform + "-" + arch +const binary = platform === "windows" ? "opencode.exe" : "opencode" + +function supportsAvx2() { + if (arch !== "x64") return false + + if (platform === "linux") { + try { + return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8")) + } catch { + return false + } + } + + if (platform === "darwin") { + try { + const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { + encoding: "utf8", + timeout: 1500, + }) + if (result.status !== 0) return false + return (result.stdout || "").trim() === "1" + } catch { + return false + } + } + + if (platform === "windows") { + const cmd = + '(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)' + + for (const exe of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) { + try { + const result = childProcess.spawnSync(exe, ["-NoProfile", "-NonInteractive", "-Command", cmd], { + encoding: "utf8", + timeout: 3000, + windowsHide: true, + }) + if (result.status !== 0) continue + const out = (result.stdout || "").trim().toLowerCase() + if (out === "true" || out === "1") return true + if (out === "false" || out === "0") return false + } catch { + continue + } + } + + return false + } + + return false +} + +const names = (() => { + const avx2 = supportsAvx2() + const baseline = arch === "x64" && !avx2 + + if (platform === "linux") { + const musl = (() => { + try { + if (fs.existsSync("/etc/alpine-release")) return true + } catch { + // ignore + } + + try { + const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) + const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase() + if (text.includes("musl")) return true + } catch { + // ignore + } + + return false + })() + + if (musl) { + if (arch === "x64") { + if (baseline) return [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] + return [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`] + } + return [`${base}-musl`, base] + } + + if (arch === "x64") { + if (baseline) return [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] + return [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`] + } + return [base, `${base}-musl`] + } + + if (arch === "x64") { + if (baseline) return [`${base}-baseline`, base] + return [base, `${base}-baseline`] + } + return [base] +})() + +function findBinary(startDir) { + let current = startDir + for (;;) { + const modules = path.join(current, "node_modules") + if (fs.existsSync(modules)) { + for (const name of names) { + const candidate = path.join(modules, name, "bin", binary) + if (fs.existsSync(candidate)) return candidate + } + } + const parent = path.dirname(current) + if (parent === current) { + return + } + current = parent + } +} + +const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir)) +if (!resolved) { + console.error( + "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing " + + names.map((n) => `\"${n}\"`).join(" or ") + + " package", + ) + process.exit(1) +} + +run(resolved) diff --git a/packages/opencode/script/bench-search.ts b/packages/opencode/script/bench-search.ts new file mode 100644 index 0000000000000000000000000000000000000000..1319eda44be51bd992b5e67ddfb035a00a08add8 --- /dev/null +++ b/packages/opencode/script/bench-search.ts @@ -0,0 +1,94 @@ +import { Effect } from "effect" +import { Fff } from "@opencode-ai/core/filesystem/fff.bun" +import { AppRuntime } from "@/effect/app-runtime" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { InstanceStore } from "@/project/instance-store" + +const dir = AbsolutePath.make(process.cwd()) + +const FILE_QUERIES = ["fff", "package.json", "tools/ experiment"] +const GREP_QUERIES = ["FileFinder", "import", "grep", "autocomplete"] +const GLOB_QUERIES = ["**/*.test.ts"] + +const FILE_LIMIT = 100 +const GREP_LIMIT = 50 +const GLOB_LIMIT = 50 + +const run = (effect: Effect.Effect) => + AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.provide({ directory: dir }, effect as never)), + ) as Promise + +// --- raw Fff picker --- +const t0 = performance.now() +const made = Fff.create({ basePath: dir, aiMode: true }) +if (!made.ok) { + console.error("Fff.create failed:", made.error) + process.exit(1) +} +const picker = made.value +console.log(`picker create: ${(performance.now() - t0).toFixed(1)}ms`) + +const tw = performance.now() +await picker.waitForScan(2_500) +console.log(`wait for scan: ${(performance.now() - tw).toFixed(1)}ms`) + +// warmup grep to let the content index build +const tWarmup = performance.now() +picker.grep("_warmup_", { mode: "regex", maxMatchesPerFile: 1, timeBudgetMs: 1_500 }) +console.log(`grep warmup: ${(performance.now() - tWarmup).toFixed(1)}ms`) + +console.log() +console.log("--- raw picker (warm) ---") + +for (const q of FILE_QUERIES) { + const t = performance.now() + const r = picker.fileSearch(q, { pageSize: Math.max(FILE_LIMIT, 100) }) + const count = r.ok ? r.value.items.length : "err" + console.log(`[picker] fileSearch "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} results)`) +} + +for (const q of GREP_QUERIES) { + const t = performance.now() + const r = picker.grep(q, { mode: "regex", pageSize: GREP_LIMIT, timeBudgetMs: 1_500 }) + const count = r.ok ? r.value.items.length : "err" + console.log(`[picker] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} matches)`) +} + +picker.destroy() + +// --- Search service: init breakdown --- +console.log() + +// 1) runtime + InstanceState + picker create + scan poll +const tRuntime = performance.now() +console.log(`[Search] init file (runtime + picker + scan): ${(performance.now() - tRuntime).toFixed(1)}ms`) + +// 2) grep warmup (content index cold-start inside the Search service picker) +const tGrepWarmup = performance.now() +await run(FileSystem.Service.use((svc) => svc.grep({ pattern: "_warmup_grep_", limit: 1 }))) +console.log(`[Search] init grep (content index warmup): ${(performance.now() - tGrepWarmup).toFixed(1)}ms`) + +console.log() +console.log("--- Search service (warm) ---") + +for (const q of FILE_QUERIES) { + const t = performance.now() + const r = await run(FileSystem.Service.use((svc) => svc.find({ query: q, limit: FILE_LIMIT }))) + console.log(`[Search.find] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} results)`) +} + +for (const q of GREP_QUERIES) { + const t = performance.now() + const r = await run(FileSystem.Service.use((svc) => svc.grep({ pattern: q, limit: GREP_LIMIT }))) + console.log(`[Search.grep] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} matches)`) +} + +for (const q of GLOB_QUERIES) { + const t = performance.now() + const r = await run(FileSystem.Service.use((svc) => svc.glob({ pattern: q, limit: GLOB_LIMIT }))) + console.log(`[Search.glob] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} files)`) +} + +process.exit(0) diff --git a/packages/opencode/script/bench-test-suite.ts b/packages/opencode/script/bench-test-suite.ts new file mode 100644 index 0000000000000000000000000000000000000000..27ff7750c2f57068b04840bbb32733f44056bea4 --- /dev/null +++ b/packages/opencode/script/bench-test-suite.ts @@ -0,0 +1,52 @@ +// Full-suite timing harness for the test-speed research in ../../perf/test-suite.md. +// Use this for periodic sanity checks; use profile-test-files.ts for discovery. +// Env: BENCH_WARMUPS=0 BENCH_RUNS=1 bun run bench:test +const warmups = Number(Bun.env.BENCH_WARMUPS ?? 0) +const runs = Number(Bun.env.BENCH_RUNS ?? 1) +const timings: number[] = [] + +if (!Number.isInteger(warmups) || warmups < 0) { + console.error("BENCH_WARMUPS must be a non-negative integer") + process.exit(1) +} +if (!Number.isInteger(runs) || runs < 1) { + console.error("BENCH_RUNS must be a positive integer") + process.exit(1) +} + +for (const index of Array.from({ length: warmups + runs }, (_, index) => index)) { + const measured = index >= warmups + const label = measured ? `run ${index - warmups + 1}/${runs}` : `warmup ${index + 1}/${warmups}` + const start = performance.now() + console.log(`bench:test ${label}`) + + const proc = Bun.spawn(["bun", "test", "--timeout", "30000"], { + cwd: import.meta.dir + "/..", + stdout: "inherit", + stderr: "inherit", + env: Bun.env, + }) + + const exitCode = await proc.exited + if (exitCode !== 0) { + console.error(`bench:test failed during ${label} with exit code ${exitCode}`) + process.exit(exitCode) + } + + const seconds = (performance.now() - start) / 1000 + console.log(`bench:test ${label} ${seconds.toFixed(3)}s`) + if (measured) timings.push(seconds) +} + +const sorted = timings.toSorted((a, b) => a - b) +const median = sorted[Math.floor(sorted.length / 2)] +const mean = timings.reduce((sum, timing) => sum + timing, 0) / timings.length +const best = sorted[0] ?? median +const worst = sorted.at(-1) ?? median + +console.log( + `bench:test median=${median.toFixed(3)}s mean=${mean.toFixed(3)}s best=${best.toFixed(3)}s worst=${worst.toFixed(3)}s`, +) +console.log(`METRIC test_suite_seconds=${median.toFixed(3)}`) +console.log(`METRIC test_suite_best_seconds=${best.toFixed(3)}`) +console.log(`METRIC test_suite_worst_seconds=${worst.toFixed(3)}`) diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts new file mode 100644 index 0000000000000000000000000000000000000000..d33ba2e4382488d8bc770cf3bf5621d3e26c7bee --- /dev/null +++ b/packages/opencode/script/build-node.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env bun + +import { Script } from "@opencode-ai/script" +import path from "path" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const dir = path.resolve(__dirname, "..") + +process.chdir(dir) + +const generated = await import("./generate.ts") + +await Bun.build({ + target: "node", + entrypoints: ["./src/node.ts"], + outdir: "./dist/node", + format: "esm", + sourcemap: "linked", + external: ["jsonc-parser", "@lydell/node-pty"], + define: { + OPENCODE_MODELS_DEV: generated.modelsData, + OPENCODE_VERSION: `'${Script.version}'`, + OPENCODE_CHANNEL: `'${Script.channel}'`, + }, + files: { + "opencode-web-ui.gen.ts": "", + }, +}) + +console.log("Build complete") diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce45f770f04e8997161e5d746374d3af2374db71 --- /dev/null +++ b/packages/opencode/script/build.ts @@ -0,0 +1,246 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import path from "path" +import { fileURLToPath } from "url" +import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const dir = path.resolve(__dirname, "..") + +process.chdir(dir) + +const generated = await import("./generate.ts") + +import { Script } from "@opencode-ai/script" +import pkg from "../package.json" + +const singleFlag = process.argv.includes("--single") +const baselineFlag = process.argv.includes("--baseline") +const skipInstall = process.argv.includes("--skip-install") +const sourcemapsFlag = process.argv.includes("--sourcemaps") +const plugin = createSolidTransformPlugin() +const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui") + +const createEmbeddedWebUIBundle = async () => { + console.log(`Building Web UI to embed in the binary`) + const appDir = path.join(import.meta.dirname, "../../app") + const dist = path.join(appDir, "dist") + await $`OPENCODE_CHANNEL=${Script.channel} bun run --cwd ${appDir} build` + const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist }))) + .map((file) => file.replaceAll("\\", "/")) + .filter((file) => !file.endsWith(".map")) + .sort() + const imports = files.map((file, i) => { + const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/") + return `import file_${i} from ${JSON.stringify(spec.startsWith(".") ? spec : `./${spec}`)} with { type: "file" };` + }) + const entries = files.map((file, i) => ` ${JSON.stringify(file)}: file_${i},`) + return [ + `// Import all files as file_$i with type: "file"`, + ...imports, + `// Export with original mappings`, + `export default {`, + ...entries, + `}`, + ].join("\n") +} + +const embeddedFileMap = skipEmbedWebUi ? null : await createEmbeddedWebUIBundle() +const treeSitterWorker = await Bun.file(fileURLToPath(import.meta.resolve("@opentui/core/parser.worker"))).text() + +const allTargets: { + os: string + arch: "arm64" | "x64" + abi?: "musl" + avx2?: false +}[] = [ + { + os: "linux", + arch: "arm64", + }, + { + os: "linux", + arch: "x64", + }, + { + os: "linux", + arch: "x64", + avx2: false, + }, + { + os: "linux", + arch: "arm64", + abi: "musl", + }, + { + os: "linux", + arch: "x64", + abi: "musl", + }, + { + os: "linux", + arch: "x64", + abi: "musl", + avx2: false, + }, + { + os: "darwin", + arch: "arm64", + }, + { + os: "darwin", + arch: "x64", + }, + { + os: "darwin", + arch: "x64", + avx2: false, + }, + { + os: "win32", + arch: "arm64", + }, + { + os: "win32", + arch: "x64", + }, + { + os: "win32", + arch: "x64", + avx2: false, + }, +] + +const targets = singleFlag + ? allTargets.filter((item) => { + if (item.os !== process.platform || item.arch !== process.arch) { + return false + } + + // When building for the current platform, prefer a single native binary by default. + // Baseline binaries require additional Bun artifacts and can be flaky to download. + if (item.avx2 === false) { + return baselineFlag + } + + // also skip abi-specific builds for the same reason + if (item.abi !== undefined) { + return false + } + + return true + }) + : allTargets + +await $`rm -rf dist` + +const binaries: Record = {} +if (!skipInstall) { + await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}` + await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}` + await $`bun install --os="*" --cpu="*" @ff-labs/fff-bun@${pkg.dependencies["@ff-labs/fff-bun"]}` +} +for (const item of targets) { + const name = [ + pkg.name, + // changing to win32 flags npm for some reason + item.os === "win32" ? "windows" : item.os, + item.arch, + item.avx2 === false ? "baseline" : undefined, + item.abi === undefined ? undefined : item.abi, + ] + .filter(Boolean) + .join("-") + console.log(`building ${name}`) + await $`mkdir -p dist/${name}/bin` + + const workerPath = "./src/cli/tui/worker.ts" + const treeSitterWorkerPath = "opentui-tree-sitter-worker.js" + const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/" + + await Bun.build({ + conditions: ["bun", "node"], + tsconfig: "./tsconfig.json", + plugins: [plugin], + external: ["node-gyp"], + format: "esm", + minify: true, + sourcemap: sourcemapsFlag ? "linked" : "none", + splitting: true, + compile: { + autoloadBunfig: false, + autoloadDotenv: false, + autoloadTsconfig: true, + autoloadPackageJson: true, + target: name.replace(pkg.name, "bun") as any, + outfile: `dist/${name}/bin/opencode`, + execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"], + windows: {}, + }, + files: { + [treeSitterWorkerPath]: treeSitterWorker, + ...(embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}), + }, + entrypoints: [ + "./src/index.ts", + workerPath, + treeSitterWorkerPath, + ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : []), + ], + define: { + FFF_LIBC: JSON.stringify(item.abi === "musl" ? "musl" : "gnu"), + OPENCODE_VERSION: `'${Script.version}'`, + OPENCODE_MODELS_DEV: generated.modelsData, + OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + treeSitterWorkerPath, + OPENCODE_WORKER_PATH: workerPath, + OPENCODE_CHANNEL: `'${Script.channel}'`, + OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "", + ...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}), + }, + }) + + // Smoke test: only run if binary is for current platform + if (item.os === process.platform && item.arch === process.arch && !item.abi) { + const binaryPath = `dist/${name}/bin/opencode` + console.log(`Running smoke test: ${binaryPath} --version`) + try { + const versionOutput = await $`${binaryPath} --version`.text() + console.log(`Smoke test passed: ${versionOutput.trim()}`) + } catch (e) { + console.error(`Smoke test failed for ${name}:`, e) + process.exit(1) + } + } + + await $`rm -rf ./dist/${name}/bin/tui` + await Bun.file(`dist/${name}/package.json`).write( + JSON.stringify( + { + name, + version: Script.version, + preferUnplugged: true, + os: [item.os], + cpu: [item.arch], + ...(item.abi ? { libc: [item.abi] } : {}), + }, + null, + 2, + ), + ) + binaries[name] = Script.version +} + +if (Script.release) { + for (const key of Object.keys(binaries)) { + if (key.includes("linux")) { + await $`tar -czf ../../${key}.tar.gz *`.cwd(`dist/${key}/bin`) + } else { + await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`) + } + } + await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}` +} + +export { binaries } diff --git a/packages/opencode/script/generate.ts b/packages/opencode/script/generate.ts new file mode 100644 index 0000000000000000000000000000000000000000..91a2d30eb7befbb88edc84bc6b6bc8644c4f40be --- /dev/null +++ b/packages/opencode/script/generate.ts @@ -0,0 +1,14 @@ +import path from "path" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const dir = path.resolve(__dirname, "..") + +process.chdir(dir) + +const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev" +export const modelsData = process.env.MODELS_DEV_API_JSON + ? await Bun.file(process.env.MODELS_DEV_API_JSON).text() + : await fetch(`${modelsUrl}/api.json`).then((x) => x.text()) +console.log("Loaded models.dev snapshot") diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts new file mode 100644 index 0000000000000000000000000000000000000000..5395a812f5506e4ecb0607b96a46590bb257db85 --- /dev/null +++ b/packages/opencode/script/httpapi-exercise.ts @@ -0,0 +1 @@ +await import("../test/server/httpapi-exercise/index") diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs new file mode 100644 index 0000000000000000000000000000000000000000..fa303b746b8c585561986f8399e4a038f1bd507c --- /dev/null +++ b/packages/opencode/script/postinstall.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node + +import childProcess from "child_process" +import fs from "fs" +import os from "os" +import path from "path" +import { createRequire } from "module" +import { fileURLToPath } from "url" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const require = createRequire(import.meta.url) +const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8")) + +const platformMap = { + darwin: "darwin", + linux: "linux", + win32: "windows", +} +const archMap = { + x64: "x64", + arm64: "arm64", + arm: "arm", +} + +const platform = platformMap[os.platform()] ?? os.platform() +const arch = archMap[os.arch()] ?? os.arch() +const base = `opencode-${platform}-${arch}` +const sourceBinary = platform === "windows" ? "opencode.exe" : "opencode" +const targetBinary = path.join(__dirname, "bin", "opencode.exe") + +function supportsAvx2() { + if (arch !== "x64") return false + + if (platform === "linux") { + try { + return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8")) + } catch { + return false + } + } + + if (platform === "darwin") { + try { + const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { + encoding: "utf8", + timeout: 1500, + }) + if (result.status !== 0) return false + return (result.stdout || "").trim() === "1" + } catch { + return false + } + } + + if (platform === "windows") { + const command = + '(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)' + + for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) { + try { + const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], { + encoding: "utf8", + timeout: 3000, + windowsHide: true, + }) + if (result.status !== 0) continue + const output = (result.stdout || "").trim().toLowerCase() + if (output === "true" || output === "1") return true + if (output === "false" || output === "0") return false + } catch { + continue + } + } + } + + return false +} + +function isMusl() { + if (platform !== "linux") return false + + try { + if (fs.existsSync("/etc/alpine-release")) return true + } catch { + // Ignore filesystem probes that are blocked by the host. + } + + try { + const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) + return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl") + } catch { + return false + } +} + +function packageNames() { + const baseline = arch === "x64" && !supportsAvx2() + + if (platform === "linux") { + if (isMusl()) { + if (arch === "x64") + return baseline + ? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] + : [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`] + return [`${base}-musl`, base] + } + + if (arch === "x64") + return baseline + ? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] + : [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`] + return [base, `${base}-musl`] + } + + if (arch === "x64") return baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`] + return [base] +} + +function resolveBinary(name) { + const packageJsonPath = require.resolve(`${name}/package.json`) + const binaryPath = path.join(path.dirname(packageJsonPath), "bin", sourceBinary) + if (!fs.existsSync(binaryPath)) throw new Error(`Binary not found at ${binaryPath}`) + return binaryPath +} + +function installPackage(name) { + const version = packageJson.optionalDependencies?.[name] + if (!version) return + + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-")) + try { + const result = childProcess.spawnSync( + "npm", + ["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${version}`], + { stdio: "inherit", windowsHide: true }, + ) + if (result.status !== 0) return + const packageDir = path.join(temp, "node_modules", name) + copyBinary(path.join(packageDir, "bin", sourceBinary), targetBinary) + return true + } finally { + fs.rmSync(temp, { recursive: true, force: true }) + } +} + +function copyBinary(source, target) { + if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`) + fs.mkdirSync(path.dirname(target), { recursive: true }) + if (fs.existsSync(target)) fs.unlinkSync(target) + try { + fs.linkSync(source, target) + } catch { + fs.copyFileSync(source, target) + } + fs.chmodSync(target, 0o755) +} + +function verifyBinary() { + const result = childProcess.spawnSync(targetBinary, ["--version"], { + encoding: "utf8", + stdio: "ignore", + windowsHide: true, + }) + return result.status === 0 +} + +function main() { + for (const name of packageNames()) { + try { + copyBinary(resolveBinary(name), targetBinary) + if (verifyBinary()) return + } catch { + if (installPackage(name) && verifyBinary()) return + } + } + + throw new Error( + `It seems your package manager failed to install the right opencode CLI package. Try manually installing ${packageNames() + .map((name) => JSON.stringify(name)) + .join(" or ")}.`, + ) +} + +try { + main() +} catch (error) { + console.error(error.message) + process.exit(1) +} diff --git a/packages/opencode/script/profile-test-files.ts b/packages/opencode/script/profile-test-files.ts new file mode 100644 index 0000000000000000000000000000000000000000..12b9bccd84bb0e1013c33265e67316ece7030965 --- /dev/null +++ b/packages/opencode/script/profile-test-files.ts @@ -0,0 +1,42 @@ +// Per-file profiler for finding candidate test-speed work; see ../../perf/test-suite.md +// for the benchmark notes, kept wins, and discarded experiments. +// Example: TEST_PROFILE_GLOB='test/server/**/*.test.ts' TEST_PROFILE_TOP=15 bun run profile:test +const pattern = Bun.env.TEST_PROFILE_GLOB ?? "test/**/*.test.{ts,tsx}" +const limit = Number(Bun.env.TEST_PROFILE_LIMIT ?? 0) +const timeout = Bun.env.TEST_PROFILE_TIMEOUT ?? "30000" +const files = Array.fromAsync(new Bun.Glob(pattern).scan({ cwd: import.meta.dir + "/..", onlyFiles: true })) + .then((files) => files.toSorted()) + .then((files) => (limit > 0 ? files.slice(0, limit) : files)) + +const results = [] +for (const file of await files) { + const start = performance.now() + const proc = Bun.spawn(["bun", "test", "--timeout", timeout, file], { + cwd: import.meta.dir + "/..", + stdout: "pipe", + stderr: "pipe", + env: Bun.env, + }) + const [output, error, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + const seconds = (performance.now() - start) / 1000 + results.push({ file, seconds, exitCode }) + console.log(`${exitCode === 0 ? "PASS" : "FAIL"} ${seconds.toFixed(3)}s ${file}`) + if (exitCode !== 0) console.log((output + error).trim()) +} + +const sorted = results.toSorted((a, b) => b.seconds - a.seconds) +console.log("\nSlowest test files:") +for (const result of sorted.slice(0, Number(Bun.env.TEST_PROFILE_TOP ?? 20))) { + console.log(`${result.seconds.toFixed(3)}s ${result.exitCode === 0 ? "PASS" : "FAIL"} ${result.file}`) +} + +if (sorted[0]) { + console.log(`METRIC slowest_test_file_seconds=${sorted[0].seconds.toFixed(3)}`) + console.log(`METRIC profiled_test_files=${results.length}`) +} + +if (results.some((result) => result.exitCode !== 0)) process.exit(1) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac1cafff56f46e9611a9ff78aa05217d21a907ef --- /dev/null +++ b/packages/opencode/script/publish.ts @@ -0,0 +1,213 @@ +#!/usr/bin/env bun +import { $ } from "bun" +import pkg from "../package.json" +import { Script } from "@opencode-ai/script" +import { fileURLToPath } from "url" + +const dir = fileURLToPath(new URL("..", import.meta.url)) +process.chdir(dir) + +async function published(name: string, version: string) { + return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +} + +async function publish(dir: string, name: string, version: string) { + // GitHub artifact downloads can drop the executable bit, and Docker uses the + // unpacked dist binaries directly rather than the published tarball. + if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir) + if (await published(name, version)) { + console.log(`already published ${name}@${version}`) + return + } + await $`bun pm pack`.cwd(dir) + await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir) +} + +const binaries: Record = {} +for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) { + const pkg = await Bun.file(`./dist/${filepath}`).json() + binaries[pkg.name] = pkg.version +} +console.log("binaries", binaries) +const version = Object.values(binaries)[0] + +await $`mkdir -p ./dist/${pkg.name}` +await $`mkdir -p ./dist/${pkg.name}/bin` +await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs` +await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text()) +await Bun.file(`./dist/${pkg.name}/bin/${pkg.name}.exe`).write( + [ + `echo "Error: ${pkg.name}-ai's postinstall script was not run." >&2`, + 'echo "" >&2', + 'echo "This occurs when using --ignore-scripts during installation, or when using a" >&2', + 'echo "package manager like pnpm that does not run postinstall scripts by default." >&2', + 'echo "" >&2', + 'echo "To fix this, run the postinstall script manually:" >&2', + `echo " cd node_modules/${pkg.name}-ai && node postinstall.mjs" >&2`, + 'echo "" >&2', + `echo "Or reinstall ${pkg.name}-ai without the --ignore-scripts flag." >&2`, + "exit 1", + "", + ].join("\n"), +) + +await Bun.file(`./dist/${pkg.name}/package.json`).write( + JSON.stringify( + { + name: pkg.name + "-ai", + bin: { + [pkg.name]: `./bin/${pkg.name}.exe`, + }, + scripts: { + postinstall: "node ./postinstall.mjs", + }, + version: version, + license: pkg.license, + os: ["darwin", "linux", "win32"], + cpu: ["arm64", "x64"], + optionalDependencies: binaries, + }, + null, + 2, + ), +) + +const tasks = Object.entries(binaries).map(async ([name]) => { + await publish(`./dist/${name}`, name, binaries[name]) +}) +await Promise.all(tasks) +await publish(`./dist/${pkg.name}`, `${pkg.name}-ai`, version) + +const image = "ghcr.io/anomalyco/opencode" +const platforms = "linux/amd64,linux/arm64" +const tags = [`${image}:${version}`, `${image}:${Script.channel}`] +const tagFlags = tags.flatMap((t) => ["-t", t]) + +// registries +if (!Script.preview) { + await $`docker buildx build --platform ${platforms} ${tagFlags} --push .` + // Calculate SHA values + const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) + const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) + const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) + const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) + + const [pkgver, _subver = ""] = Script.version.split(/(-.*)/, 2) + + // arch + const binaryPkgbuild = [ + "# Maintainer: dax", + "# Maintainer: adam", + "", + "pkgname='opencode-bin'", + `pkgver=${pkgver}`, + `_subver=${_subver}`, + "options=('!debug' '!strip')", + "pkgrel=1", + "pkgdesc='The AI coding agent built for the terminal.'", + "url='https://github.com/anomalyco/opencode'", + "arch=('aarch64' 'x86_64')", + "license=('MIT')", + "provides=('opencode')", + "conflicts=('opencode')", + "depends=('ripgrep')", + "", + `source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.tar.gz")`, + `sha256sums_aarch64=('${arm64Sha}')`, + + `source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.tar.gz")`, + `sha256sums_x86_64=('${x64Sha}')`, + "", + "package() {", + ' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"', + "}", + "", + ].join("\n") + + for (const [pkg, pkgbuild] of [["opencode-bin", binaryPkgbuild]]) { + for (let i = 0; i < 30; i++) { + try { + await $`rm -rf ./dist/aur-${pkg}` + await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}` + await $`cd ./dist/aur-${pkg} && git checkout master` + await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild) + await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO` + await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO` + if ((await $`cd ./dist/aur-${pkg} && git diff --cached --quiet`.nothrow()).exitCode === 0) break + await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"` + await $`cd ./dist/aur-${pkg} && git push` + break + } catch { + continue + } + } + } + + // Homebrew formula + const homebrewFormula = [ + "# typed: false", + "# frozen_string_literal: true", + "", + "# This file was generated by GoReleaser. DO NOT EDIT.", + "class Opencode < Formula", + ` desc "The AI coding agent built for the terminal."`, + ` homepage "https://github.com/anomalyco/opencode"`, + ` version "${Script.version.split("-")[0]}"`, + "", + ` depends_on "ripgrep"`, + "", + " on_macos do", + " if Hardware::CPU.intel?", + ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-darwin-x64.zip"`, + ` sha256 "${macX64Sha}"`, + "", + " def install", + ' bin.install "opencode"', + " end", + " end", + " if Hardware::CPU.arm?", + ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-darwin-arm64.zip"`, + ` sha256 "${macArm64Sha}"`, + "", + " def install", + ' bin.install "opencode"', + " end", + " end", + " end", + "", + " on_linux do", + " if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?", + ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-linux-x64.tar.gz"`, + ` sha256 "${x64Sha}"`, + " def install", + ' bin.install "opencode"', + " end", + " end", + " if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?", + ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-linux-arm64.tar.gz"`, + ` sha256 "${arm64Sha}"`, + " def install", + ' bin.install "opencode"', + " end", + " end", + " end", + "end", + "", + "", + ].join("\n") + + const token = process.env.GITHUB_TOKEN + if (!token) { + console.error("GITHUB_TOKEN is required to update homebrew tap") + process.exit(1) + } + const tap = `https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git` + await $`rm -rf ./dist/homebrew-tap` + await $`git clone ${tap} ./dist/homebrew-tap` + await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula) + await $`cd ./dist/homebrew-tap && git add opencode.rb` + if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode !== 0) { + await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"` + await $`cd ./dist/homebrew-tap && git push` + } +} diff --git a/packages/opencode/script/run-workspace-server b/packages/opencode/script/run-workspace-server new file mode 100644 index 0000000000000000000000000000000000000000..4371a157976ecaa5691f87904eec2e846a0cab44 --- /dev/null +++ b/packages/opencode/script/run-workspace-server @@ -0,0 +1,106 @@ +#!/usr/bin/env bun + +// This script runs a separate OpenCode server to be used as a remote +// workspace, simulating a remote environment but all local to make +// debugger easier +// +// *Important*: make sure you add the debug workspace plugin first. +// In `.opencode/opencode.jsonc` in the root of this project add: +// +// "plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"] +// +// Afterwards, run `./packages/opencode/script/run-workspace-server` + +import { stat } from "node:fs/promises" +import { setTimeout as sleep } from "node:timers/promises" + +const DEV_DATA_FILE = "/tmp/opencode-workspace-dev-data.json" +const RESTART_POLL_INTERVAL = 250 + +async function readData() { + return await Bun.file(DEV_DATA_FILE).json() +} + +async function readDataMtime() { + return await stat(DEV_DATA_FILE) + .then((info) => info.mtimeMs) + .catch((error) => { + if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { + return undefined + } + + throw error + }) +} + +async function readSnapshot() { + while (true) { + try { + const before = await readDataMtime() + if (before === undefined) { + await sleep(RESTART_POLL_INTERVAL) + continue + } + + const data = await readData() + const after = await readDataMtime() + + if (before === after) { + return { data, mtime: after } + } + } catch (error) { + if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { + await sleep(RESTART_POLL_INTERVAL) + continue + } + + throw error + } + } +} + +function startDevServer(data: any) { + const env = Object.fromEntries(Object.entries(data.env ?? {}).filter(([, value]) => value !== undefined)) + + return Bun.spawn(["bun", "run", "dev", "serve", "--port", String(data.port), "--print-logs"], { + env: { + ...process.env, + ...env, + XDG_DATA_HOME: "/tmp/data", + }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) +} + +async function waitForRestartSignal(mtime: number, signal: AbortSignal) { + while (!signal.aborted) { + await sleep(RESTART_POLL_INTERVAL) + if (signal.aborted) return false + if ((await readDataMtime()) !== mtime) return true + } + + return false +} + +while (true) { + const { data, mtime } = await readSnapshot() + const proc = startDevServer(data) + const restartAbort = new AbortController() + + const result = await Promise.race([ + proc.exited.then((code) => ({ type: "exit" as const, code })), + waitForRestartSignal(mtime, restartAbort.signal).then((restart) => ({ type: "restart" as const, restart })), + ]) + + restartAbort.abort() + + if (result.type === "restart" && result.restart) { + proc.kill() + await proc.exited + continue + } + + process.exit(result.code) +} diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..0aa4e2068efe358807ee9794ebee2e9d0dc86ffe --- /dev/null +++ b/packages/opencode/script/schema.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env bun + +import { Config } from "@/config/config" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { TuiConfig } from "@opencode-ai/tui/config" +import { Schema } from "effect" + +type JsonSchema = Record +const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model" + +function generateEffect(schema: Schema.Top) { + const document = Schema.toJsonSchemaDocument(schema) + const normalized = normalize({ + $schema: "https://json-schema.org/draft/2020-12/schema", + ...document.schema, + $defs: document.definitions, + }) + if (!isRecord(normalized)) throw new Error("schema generator produced a non-object schema") + const restored = restoreModelRefs(normalized) + if (!isRecord(restored)) throw new Error("schema generator produced a non-object schema") + restored.allowComments = true + restored.allowTrailingCommas = true + return restored +} + +function normalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalize) + if (!isRecord(value)) return value + + const schema = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalize(item)])) + + if (Array.isArray(schema.anyOf)) { + const anyOf = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null") + if (anyOf.length !== schema.anyOf.length) { + const { anyOf: _, ...rest } = schema + if (anyOf.length === 1 && isRecord(anyOf[0])) return normalize({ ...anyOf[0], ...rest }) + return { ...rest, anyOf } + } + } + + if (Array.isArray(schema.allOf) && schema.allOf.length === 1 && isRecord(schema.allOf[0])) { + const { allOf: _, ...rest } = schema + return normalize({ ...schema.allOf[0], ...rest }) + } + + if (schema.type === "integer" && schema.maximum === undefined) { + return { ...schema, maximum: Number.MAX_SAFE_INTEGER } + } + + return schema +} + +function restoreModelRefs(value: unknown, key?: string): unknown { + if (Array.isArray(value)) return value.map((item) => restoreModelRefs(item)) + if (!isRecord(value)) return value + + const schema = Object.fromEntries(Object.entries(value).map(([name, item]) => [name, restoreModelRefs(item, name)])) + if ((key === "model" || key === "small_model") && schema.type === "string") { + return { ...schema, $ref: MODEL_REF } + } + return schema +} + +function isRecord(value: unknown): value is JsonSchema { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +const configFile = process.argv[2] +const tuiFile = process.argv[3] + +console.log(configFile) +await Bun.write(configFile, JSON.stringify(generateEffect(ConfigV1.Info), null, 2)) + +if (tuiFile) { + console.log(tuiFile) + await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiConfig.Info), null, 2)) +} diff --git a/packages/opencode/script/time.ts b/packages/opencode/script/time.ts new file mode 100644 index 0000000000000000000000000000000000000000..0db795ed0a4eea4a8d90611833a0fcce051ffdd2 --- /dev/null +++ b/packages/opencode/script/time.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env bun + +import path from "path" +const toDynamicallyImport = path.join(process.cwd(), process.argv[2]) +await import(toDynamicallyImport) +console.log(performance.now()) diff --git a/packages/opencode/script/trace-imports.ts b/packages/opencode/script/trace-imports.ts new file mode 100644 index 0000000000000000000000000000000000000000..60f1d5ff34f007ef46652dd104684a1185ae945e --- /dev/null +++ b/packages/opencode/script/trace-imports.ts @@ -0,0 +1,153 @@ +#!/usr/bin/env bun +import * as path from "path" +import * as ts from "typescript" + +const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode" + +// Get entry file from command line arg or use default +const ENTRY_FILE = process.argv[2] || "src/plugin/tui/runtime.ts" + +const visited = new Set() + +function resolveImport(importPath: string, fromFile: string): string | null { + if (importPath.startsWith("@/")) { + return path.join(BASE_DIR, "src", importPath.slice(2)) + } + + if (importPath.startsWith("./") || importPath.startsWith("../")) { + const dir = path.dirname(fromFile) + return path.resolve(dir, importPath) + } + + return null +} + +function isInternalImport(importPath: string): boolean { + return importPath.startsWith("@/") || importPath.startsWith("./") || importPath.startsWith("../") +} + +async function tryExtensions(filePath: string): Promise { + const extensions = [".ts", ".tsx", ".js", ".jsx"] + + try { + const file = Bun.file(filePath) + const stat = await file.stat() + + if (stat?.isDirectory()) { + for (const ext of extensions) { + const indexPath = path.join(filePath, "index" + ext) + const indexFile = Bun.file(indexPath) + if (await indexFile.exists()) return indexPath + } + return null + } + + // It's a file + return filePath + } catch { + // Path doesn't exist, try adding extensions + for (const ext of extensions) { + const withExt = filePath + ext + const extFile = Bun.file(withExt) + if (await extFile.exists()) return withExt + } + return null + } +} + +function extractImports(sourceFile: ts.SourceFile): string[] { + const imports: string[] = [] + + function visit(node: ts.Node) { + // import x from "path" or import { x } from "path" + if (ts.isImportDeclaration(node)) { + // Skip type-only imports + if (node.importClause?.isTypeOnly) return + + const moduleSpec = node.moduleSpecifier + if (ts.isStringLiteral(moduleSpec)) { + imports.push(moduleSpec.text) + } + } + + // export { x } from "path" + if (ts.isExportDeclaration(node) && node.moduleSpecifier) { + if (ts.isStringLiteral(node.moduleSpecifier)) { + imports.push(node.moduleSpecifier.text) + } + } + + // Dynamic import: import("path") + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const arg = node.arguments[0] + if (arg && ts.isStringLiteral(arg)) { + imports.push(arg.text) + } + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return imports +} + +async function traceFile(filePath: string, depth = 0): Promise { + const normalizedPath = path.relative(BASE_DIR, filePath) + + if (visited.has(filePath)) { + return + } + + // Only trace TypeScript/JavaScript files + if (!filePath.match(/\.(ts|tsx|js|jsx)$/)) { + return + } + + visited.add(filePath) + console.log("\t".repeat(depth) + normalizedPath) + + let content: string + try { + content = await Bun.file(filePath).text() + } catch { + return + } + + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true) + + const imports = extractImports(sourceFile) + const internalImports = imports.filter(isInternalImport) + const externalImports = imports.filter((imp) => !isInternalImport(imp)) + + // Print external imports + for (const imp of externalImports) { + console.log("\t".repeat(depth + 1) + `[ext] ${imp}`) + } + + for (const imp of internalImports) { + const resolved = resolveImport(imp, filePath) + if (!resolved) continue + + const actualPath = await tryExtensions(resolved) + if (!actualPath) continue + + await traceFile(actualPath, depth + 1) + } +} + +async function main() { + const entryPath = path.join(BASE_DIR, ENTRY_FILE) + + // Check if file exists + const file = Bun.file(entryPath) + if (!(await file.exists())) { + console.error(`File not found: ${ENTRY_FILE}`) + console.error(`Resolved to: ${entryPath}`) + process.exit(1) + } + + await traceFile(entryPath) +} + +main().catch(console.error) diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md new file mode 100644 index 0000000000000000000000000000000000000000..5be155d1b860f140440011fe6d4520af5fdee799 --- /dev/null +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -0,0 +1,204 @@ +# OpenAPI Translation Cleanup Plan + +## Goal + +Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface. + +The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation. + +## Current Culprit + +`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility. + +The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime. + +## Non-Negotiables + +- Do not break the generated JavaScript SDK without an explicit versioned migration plan. +- Runtime route schemas are the source of truth for accepted params, payloads, and responses. +- `/doc`, generated SDK types, and runtime validation must agree for every endpoint. +- Prefer endpoint or schema annotations over post-generation spec surgery. +- Remove one category of rewrite at a time, with focused compatibility checks. + +## PR Checklist + +Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started. + +Current combined PR scope: + +- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes. +- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs. + +### PR 1: Add OpenAPI/Runtime Query Drift Tests + +- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` Import `OpenApi.fromApi` and `PublicApi`. +- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`. +- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes. +- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema. +- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params. +- `[x]` Keep this part test-only. + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 2: Delete Spec-Only Workspace Query Injection + +- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- `[x]` Delete `InstanceQueryParameters`. +- `[x]` Delete the `isInstanceRoute` constant. +- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation. +- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`. +- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes. +- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only. + +Notes: + +- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params. +- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing. + +Expected code shape: + +```ts +for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`) +``` + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `[x]` `./packages/sdk/js/script/build.ts` from repo root. +- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers + +- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time. +- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`. +- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`. +- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet. + +Concrete first targets: + +- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform. +- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter. +- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly. + +Verification: + +- Focused HTTP tests for changed query fields. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK request param types before deleting each override. +- `bun typecheck` from `packages/opencode`. + +### PR 4: Move Path Parameter Patterns Into ID Schemas + +- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`. +- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions. +- Add or fix OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. +- Delete one path override only after generated OpenAPI is unchanged for that param. + +Concrete first targets: + +- `[x]` `sessionID` +- `[x]` `messageID` +- `[x]` `partID` +- `[x]` `permissionID` +- `[x]` `ptyID` + +- `[x]` Remove ambiguous workspace `id` path overrides once the endpoint source schema emits the `wrk` pattern. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated path param types and patterns. +- `bun typecheck` from `packages/opencode`. + +### PR 5: Replace Built-In Error Rewrites With Declared API Errors + +- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`. +- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there. +- Update handlers to fail with the declared API errors at the boundary. +- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible. +- Do this group by group, starting with one small route group. + +Concrete first targets: + +- `groups/config.ts` `PATCH /config` bad request. +- `groups/session.ts` endpoints that already translate domain not-found errors. +- `groups/file.ts` if any handler currently relies on built-in error shape. + +Verification: + +- Focused HTTP tests asserting response body shape for changed error paths. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect SDK error union diff. +- `bun typecheck` from `packages/opencode`. + +### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It + +- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`. +- Decide whether SDK should expose auth in generated operation metadata. +- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code. +- If removing it, update SDK generation expectations and docs in the same PR. + +Verification: + +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated client call signatures and error unions. +- Do not merge if auth churn changes normal SDK call ergonomics unintentionally. + +### PR 7: Tackle Component Shape Rewrites One At A Time + +- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`. +- For each rewrite, make a tiny PR that removes or narrows only that rewrite. +- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first. + +Concrete first targets: + +- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially. +- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source. +- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK type-name and optionality diffs. + +## Upstream Middleware Query Support + +Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation. + +Target in `effect-smol`: + +- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation. +- Make runtime request decoding include middleware query schemas. +- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware. + +Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`. + +## Suggested PR Order + +1. Add drift detection tests only. +2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas. +3. Convert query type overrides into route/schema-level helpers where possible. +4. Convert path parameter overrides into schema annotations or upstream fixes. +5. Replace built-in error response rewrites with explicit declared API errors by route group. +6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable. + +## Verification Checklist Per PR + +- Focused HTTP tests for changed routes. +- OpenAPI drift tests. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK diff for public API churn. +- `bun typecheck` from `packages/opencode`. diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md new file mode 100644 index 0000000000000000000000000000000000000000..06f7757d5137d92caca60c7a6eaafa4907ea8dd7 --- /dev/null +++ b/packages/opencode/specs/tui-plugins.md @@ -0,0 +1,544 @@ +# TUI plugins + +Technical reference for the current TUI plugin system. + +## Overview + +- TUI plugin config lives in `tui.json`. +- Author package entrypoint is `@opencode-ai/plugin/tui`. +- Internal plugins load inside the CLI app the same way external TUI plugins do. +- Package plugins can be installed from CLI or TUI. +- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both. +- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing. +- npm packages can be TUI theme-only via `package.json["oc-themes"]` without a `./tui` entrypoint. + +## TUI config + +Example: + +```json +{ + "$schema": "https://opencode.ai/tui.json", + "theme": "smoke-theme", + "leader_timeout": 2000, + "keybinds": { + "leader": "ctrl+x", + "command_list": "ctrl+p", + "session_new": "n" + }, + "plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]], + "plugin_enabled": { + "acme.demo": false + }, + "attention": { + "enabled": true, + "notifications": true, + "sound": true, + "volume": 0.4, + "sound_pack": "opencode.default", + "sounds": { + "error": "/Users/me/sounds/error.mp3" + } + } +} +``` + +- `plugin` entries can be either a string spec or `[spec, options]`. +- Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths. +- Relative path specs are resolved relative to the config file that declared them. +- A file module listed in `tui.json` must be a TUI module (`default export { id?, tui }`) and must not export `server`. +- Duplicate npm plugins are deduped by package name; higher-precedence config wins. +- Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded. +- `plugin_enabled` is keyed by plugin id, not by plugin spec. +- For file plugins, that id must come from the plugin module's exported `id`. For npm plugins, it is the exported `id` or the package name if `id` is omitted. +- Plugins are enabled by default. `plugin_enabled` is only for explicit overrides, usually to disable a plugin with `false`. +- Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id. +- `plugin_enabled` is merged across config layers. +- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup. +- `attention.enabled` defaults to `false`; when `false`, it disables all `api.attention.notify(...)` delivery. +- `attention.notifications` and `attention.sound` independently control terminal-mediated desktop notifications and built-in sounds. +- `attention.volume` sets the default built-in sound volume from `0` to `1`. +- `attention.sound_pack` selects the initial semantic sound pack. Persisted runtime selection in KV can override it. +- `attention.sounds` overrides individual semantic sound slots such as `error`, `done`, or `subagent_done`. +- `leader_timeout` is a top-level TUI setting. +- `keybinds` is a flat object keyed by command id; values are key binding values (`false`, `"none"`, a key string/object, a binding object, or an array of key strings/objects/binding objects). +- `keybinds.leader` sets the key used by `` shortcuts. + +## Author package shape + +Package entrypoint: + +- Import types from `@opencode-ai/plugin/tui`. +- `@opencode-ai/plugin` exports `./tui` and declares optional peer deps on `@opentui/core` and `@opentui/solid`. + +Minimal module shape: + +```tsx +/** @jsxImportSource @opentui/solid */ +import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" + +const tui: TuiPlugin = async (api, options, meta) => { + api.keymap.registerLayer({ + commands: [ + { + name: "demo.open", + title: "Demo", + category: "Plugin", + namespace: "palette", + slashName: "demo", + run() { + api.route.navigate("demo") + }, + }, + ], + bindings: [{ key: "ctrl+shift+m", cmd: "demo.open", desc: "Open demo" }], + }) + + api.route.register([ + { + name: "demo", + render: () => ( + + demo + + ), + }, + ]) +} + +const plugin: TuiPluginModule & { id: string } = { + id: "acme.demo", + tui, +} + +export default plugin +``` + +- Loader only reads the module default export object. Named exports are ignored. +- TUI shape is `default export { id?, tui }`; including `server` is rejected. +- A single module cannot export both `server` and `tui`. +- `tui` signature is `(api, options, meta) => Promise`. +- If package `exports` contains `./tui`, the loader resolves that entrypoint. +- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`. +- For npm package specs, TUI does not use `package.json` `main` as a fallback entry. +- `package.json` `main` is only used for server plugin entrypoint resolution. +- If a configured TUI package has no `./tui` entrypoint and no valid `oc-themes`, it is skipped with a warning (not a load failure). +- If a configured TUI package has no `./tui` entrypoint but has valid `oc-themes`, runtime creates a no-op module record and still loads it for theme sync and plugin state. +- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module. +- File/path plugins must export a non-empty `id`. +- npm plugins may omit `id`; package `name` is used. +- Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids. +- If a path spec points at a directory, server loading can use `package.json` `main`. +- TUI path loading never uses `package.json` `main`. +- Legacy compatibility: path specs like `./plugin` can resolve to `./plugin/index.ts` (or `index.js`) when `package.json` is missing. +- The `./plugin -> ./plugin/index.*` fallback applies to both server and TUI v1 loading. +- There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`. + +## Package manifest and install + +Install target detection is inferred from `package.json` entrypoints and theme metadata: + +- `server` target when `exports["./server"]` exists or `main` is set. +- `tui` target when `exports["./tui"]` exists. +- `tui` target when `oc-themes` exists and resolves to a non-empty set of valid package-relative theme paths. + +`oc-themes` rules: + +- `oc-themes` is an array of relative paths. +- Absolute paths and `file://` paths are rejected. +- Resolved theme paths must stay inside the package directory. +- Invalid `oc-themes` causes manifest read failure for install. + +Example: + +```json +{ + "name": "@acme/opencode-plugin", + "type": "module", + "main": "./dist/server.js", + "exports": { + "./server": { + "import": "./dist/server.js", + "config": { "custom": true } + }, + "./tui": { + "import": "./dist/tui.js", + "config": { "compact": true } + } + }, + "engines": { + "opencode": "^1.0.0" + } +} +``` + +### Version compatibility + +npm plugins can declare a version compatibility range in `package.json` using the standard `engines` field: + +```json +{ + "engines": { + "opencode": "^1.0.0" + } +} +``` + +- The value is a semver range checked against the running OpenCode version. +- If the range is not satisfied, the plugin is skipped with a warning and a session error. +- If `engines.opencode` is absent, no check is performed (backward compatible). +- File plugins are never checked; only npm package plugins are validated. + +- Install flow is shared by CLI and TUI in `src/plugin/install.ts`. +- Shared helpers are `installPlugin`, `readPluginManifest`, and `patchPluginConfig`. +- `opencode plugin ` and TUI install both run install → manifest read → config patch. +- Alias: `opencode plug `. +- `-g` / `--global` writes into the global config dir. +- Local installs resolve target dir inside `patchPluginConfig`. +- For local scope, path is `/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `/.opencode`. +- Root-worktree fallback (`worktree === "/"` uses `/.opencode`) is covered by regression tests. +- `patchPluginConfig` applies all detected targets (`server` and/or `tui`) in one call. +- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors. +- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`. +- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced. +- npm plugin package installs are executed with `--ignore-scripts`, so package `install` / `postinstall` lifecycle scripts are not run. +- `exports["./server"].config` and `exports["./tui"].config` can provide default plugin options written on first install. +- Without `--force`, an already-configured npm package name is a no-op. +- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept. +- Explicit npm specs with a version suffix (for example `pkg@1.2.3`) are pinned. Runtime install requests that exact version and does not run stale/latest checks for newer registry versions. +- Bare npm specs (`pkg`) are treated as `latest` and can refresh when the cached version is stale. +- Tuple targets in `oc-plugin` provide default options written into config. +- A package can target `server`, `tui`, or both. +- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module. +- There is no uninstall, list, or update CLI command for external plugins. +- Local file plugins are configured directly in `tui.json`. + +When `plugin` entries exist in a writable `.opencode` dir or `OPENCODE_CONFIG_DIR`, OpenCode installs `@opencode-ai/plugin` into that dir and writes: + +- `package.json` +- `bun.lock` +- `node_modules/` +- `.gitignore` + +That is what makes local config-scoped plugins able to import `@opencode-ai/plugin/tui`. + +## TUI plugin API + +Top-level API groups exposed to `tui(api, options, meta)`: + +- `api.app.version` +- `api.attention.notify(input)` +- `api.keys.formatSequence(parts)`, `formatBindings(bindings)` +- `api.keymap` +- `api.mode.current()`, `api.mode.push(mode)` +- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current` +- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Slot`, `Prompt`, `ui.toast`, `ui.dialog` +- `api.tuiConfig` +- `api.kv.get`, `set`, `ready` +- `api.state` +- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready` +- `api.client` +- `api.event.on(type, handler)` +- `api.renderer` +- `api.slots.register(plugin)` +- `api.plugins.list()`, `activate(id)`, `deactivate(id)`, `add(spec)`, `install(spec, options?)` +- `api.lifecycle.signal`, `api.lifecycle.onDispose(fn)` + +### Keymap + +- `api.keymap` exposes the raw `Keymap` instance from the host. +- The host already installs the default OpenTUI bundle (`default keys`, metadata fields, and enabled fields) plus OpenCode's comma bindings, leader token, base layout fallback, pending-sequence helpers, and managed textarea layer. +- Register commands with `api.keymap.registerLayer({ commands: [...] })`. +- Register key bindings with `bindings: [{ key, cmd, desc }]` in the same layer or a separate layer. +- Use `api.keymap.acquireResource(...)` for shared plugin addon setup that should ref-count against the host keymap. +- To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command. +- Use `api.keymap.dispatchCommand(name)` for user-style execution semantics and `api.keymap.runCommand(name)` only for forced programmatic execution. +- Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself. +- Built-in which-key shortcuts are resolved from flat `keybinds` command ids such as `which_key_toggle`, not plugin options. + +#### Mode-aware layers + +OpenCode registers a `mode` layer field on the host keymap. Plugins can use it to keep bindings active only in the relevant UI state. + +Built-in modes: + +- `base`: normal app, route, and prompt interaction. +- `modal`: host dialog stack is open, including dialogs rendered through `api.ui.dialog` and `api.ui.Dialog*` components. +- `autocomplete`: host prompt autocomplete is open. +- `api.mode.current()` returns the active top mode, or `base` when no pushed mode is active. + +Example: register a command and shortcut that are active only in normal app mode: + +```tsx +api.keymap.registerLayer({ + mode: "base", + commands: [ + { + name: "demo.open", + title: "Demo", + category: "Plugin", + namespace: "palette", + run() { + api.route.navigate("demo") + }, + }, + ], + bindings: [{ key: "ctrl+shift+m", cmd: "demo.open", desc: "Open demo" }], +}) +``` + +Layers without `mode` are not mode-gated and can remain active while dialogs or autocomplete are open. Use that only for intentionally global commands or low-level keymap extensions. + +Plugins that own a full-screen route or modal-like UI can temporarily push a plugin-specific mode with `api.mode.push(...)`. Use a plugin-scoped mode name. The returned disposer pops that specific stack entry and is idempotent, so popping an older mode while a newer mode is on top leaves the newer mode active. + +```tsx +import { onCleanup } from "solid-js" + +api.route.register([ + { + name: "demo", + render: () => { + const popMode = api.mode.push("acme.demo") + onCleanup(popMode) + + return ( + + demo + + ) + }, + }, +]) + +api.keymap.registerLayer({ + mode: "acme.demo", + bindings: [{ key: "escape", cmd: () => api.route.navigate("home"), desc: "Close demo" }], +}) +``` + +Mode pushes are automatically tracked by the plugin runtime. If a plugin is disabled, fails during activation, or the TUI shuts down before the plugin calls the disposer, OpenCode pops the plugin's pushed modes during plugin cleanup. Calling the disposer yourself is still recommended for component lifetimes; cleanup remains idempotent. + +### Keys + +- `api.keys` exposes host-formatted shortcut display helpers for plugin UI. +- `formatSequence(parts)` formats parsed key sequence parts using the host's display policy. +- `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show. +- For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`. + +### Attention + +- `api.attention.notify({ title?, message, notification?, sound? })` requests user attention while keeping terminal focus, notifications, and audio owned by the host. +- `message` is required; `title` defaults to `"opencode"`; `notification` defaults to enabled with `when: "blurred"`; `sound` defaults to enabled with `when: "always"`. +- `when: "always"` requests delivery regardless of terminal focus state. +- `when: "focused"` only requests delivery after the terminal is known focused; `when: "blurred"` only requests delivery after the terminal is known blurred. +- Example: `notification: { when: "blurred" }, sound: { name: "question", when: "always" }` plays sound while focused but only triggers system notifications when blurred. +- Semantic sound names are `"default"`, `"question"`, `"permission"`, `"error"`, `"done"`, and `"subagent_done"`. +- `sound: true` plays the `"default"` sound; `sound: { name: "question" }` plays a named semantic sound. +- `sound: { volume }` overrides volume for that call; `sound: false` disables sound for that call; `notification: false` disables system notification for that call. +- `api.attention.soundboard.registerPack({ id, name?, sounds })` registers a sound pack and returns a disposer. Relative paths resolve from the plugin root and are cleaned up on plugin deactivation. +- `api.attention.soundboard.activate(id, { persist })` selects the active pack. `persist: true` writes the selected pack id to TUI KV state, not `tui.json`. +- `api.attention.soundboard.current()` and `list()` expose the active/registered packs for plugin UX. +- Config `attention.sounds` overrides active-pack sounds by slot. Failed loads fall back to the active pack and then `opencode.default`. +- The host strips ANSI/control characters and collapses newlines before sending text to the terminal notification API. +- Terminal and OS settings decide whether a requested notification is visibly displayed. +- Prefer privacy-safe messages such as `"A question needs your input"`; avoid full commands, paths, prompts, errors, secrets, or file contents unless the plugin intentionally exposes them. + +### Routes + +- Reserved route names: `home` and `session`. +- Any other name is treated as a plugin route. +- `api.route.current` returns one of: + - `{ name: "home" }` + - `{ name: "session", params: { sessionID, initialPrompt? } }` + - `{ name: string, params?: Record }` +- `api.route.navigate("session", params)` only uses `params.sessionID`. It cannot set `initialPrompt`. +- If multiple plugins register the same route name, the last registered route wins. +- Unknown plugin routes render a fallback screen with a `go home` action. + +### Dialogs and toast + +- `ui.Dialog` is the base dialog wrapper. +- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components. +- `ui.Slot` renders host or plugin-defined slots by name from plugin JSX. +- `ui.Prompt` renders the same prompt component used by the host app and accepts `sessionID`, `workspaceID`, `ref`, and `right` for the prompt meta row's right side. +- `ui.toast(...)` shows a toast. +- `ui.dialog` exposes the host dialog stack: + - `replace(render, onClose?)` + - `clear()` + - `setSize("medium" | "large" | "xlarge")` + - readonly `size`, `depth`, `open` + +### KV, state, client, events + +- `api.kv` is the shared app KV store backed by `state/kv.json`. It is not plugin-namespaced. +- `api.kv` exposes `ready`. +- `api.tuiConfig` and `api.state` are live host objects/getters, not frozen snapshots. +- `api.state` exposes synced TUI state: + - `ready` + - `config` + - `provider` + - `path.{state,config,worktree,directory}` + - `vcs?.branch` + - `session.count()` + - `session.diff(sessionID)` + - `session.todo(sessionID)` + - `session.messages(sessionID)` + - `session.status(sessionID)` + - `session.permission(sessionID)` + - `session.question(sessionID)` + - `part(messageID)` + - `lsp()` + - `mcp()` +- `api.client` always reflects the current runtime client. +- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function. +- `api.renderer` exposes the raw `CliRenderer`. + +### Theme + +- `api.theme.current` exposes the resolved current theme tokens. +- `api.theme.selected` is the selected theme name. +- `api.theme.has(name)` checks for an installed theme. +- `api.theme.set(name)` switches theme and returns `boolean`. +- `api.theme.mode()` returns `"dark" | "light"`. +- `api.theme.install(jsonPath)` installs a theme JSON file. +- `api.theme.ready` reports theme readiness. + +Theme install behavior: + +- Relative theme paths are resolved from the plugin root. +- Theme name is the JSON basename. +- `api.theme.install(...)` and `oc-themes` auto-sync share the same installer path. +- Theme copy/write runs under cross-process lock key `tui-theme:`. +- First install writes only when the destination file is missing. +- If the theme name already exists, install is skipped unless plugin metadata state is `updated`. +- On `updated`, host skips rewrite when tracked `mtime`/`size` is unchanged. +- When a theme already exists and state is not `updated`, host can still persist theme metadata when destination already exists. +- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source. +- Global plugins persist installed themes under the global `themes` dir. +- Invalid or unreadable theme files are ignored. + +### Slots + +Current host slot names: + +- `app` +- `app_bottom` +- `home_logo` +- `home_prompt` with props `{ workspace_id?, ref? }` +- `home_prompt_right` with props `{ workspace_id? }` +- `session_prompt` with props `{ session_id, visible?, disabled?, on_submit?, ref? }` +- `session_prompt_right` with props `{ session_id }` +- `home_bottom` +- `home_footer` +- `sidebar_title` with props `{ session_id, title, share_url? }` +- `sidebar_content` with props `{ session_id }` +- `sidebar_footer` with props `{ session_id }` + +Slot notes: + +- Slot context currently exposes only `theme`. +- `api.slots.register(plugin)` returns the host-assigned slot plugin id. +- `api.slots.register(plugin)` does not return an unregister function. +- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on. +- Plugin-provided `id` is not allowed. +- The current host renders `home_logo`, `home_prompt`, and `session_prompt` with `replace`, `home_footer`, `sidebar_title`, and `sidebar_footer` with `single_winner`, and `app`, `app_bottom`, `home_prompt_right`, `session_prompt_right`, `home_bottom`, and `sidebar_content` with the slot library default mode. +- `app_bottom` is rendered in normal layout flow below the active route, while `app` is rendered afterward for global app-level UI. +- Plugins can define custom slot names in `api.slots.register(...)` and render them from plugin UI with `ui.Slot`. + +### Plugin control and lifecycle + +- `api.plugins.list()` returns `{ id, source, spec, target, enabled, active }[]`. +- `enabled` is the persisted desired state. `active` means the plugin is currently initialized. +- `api.plugins.activate(id)` sets `enabled=true`, persists it into KV, and initializes the plugin. +- `api.plugins.deactivate(id)` sets `enabled=false`, persists it into KV, and disposes the plugin scope. +- `api.plugins.add(spec)` trims the input and returns `false` for an empty string. +- `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`. +- `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded. +- `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state). +- `api.plugins.add(spec)` can load theme-only packages (`oc-themes` with no `./tui`) as runtime entries. +- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install. +- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`. +- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install. +- If activation fails, the plugin can remain `enabled=true` and `active=false`. +- `api.lifecycle.signal` is aborted before cleanup runs. +- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function. + +## Plugin metadata + +`meta` passed to `tui(api, options, meta)` contains: + +- `state`: `first | updated | same` +- `id`, `source`, `spec`, `target` +- npm-only fields when available: `requested`, `version` +- file-only field when available: `modified` +- `first_time`, `last_time`, `time_changed`, `load_count`, `fingerprint` + +Metadata is persisted by plugin id. + +- File plugin fingerprint is `target|modified`. +- npm plugin fingerprint is `target|requested|version`. +- Internal plugins get synthetic metadata with `state: "same"`. + +## Runtime behavior + +- Internal TUI plugins load first. +- External TUI plugins load from `tuiConfig.plugin`. +- `--pure` / `OPENCODE_PURE` skips external TUI plugins only. +- External plugin resolution and import are parallel. +- Packages with no `./tui` entrypoint and valid `oc-themes` are loaded as synthetic no-op TUI plugin modules. +- Theme-only packages loaded this way appear in `api.plugins.list()` and plugin manager rows like other external plugins. +- Packages with no `./tui` entrypoint and no valid `oc-themes` are skipped with warning. +- External plugin activation is sequential to keep command, route, and side-effect order deterministic. +- Theme auto-sync from `oc-themes` runs before plugin `tui(...)` execution and only on metadata state `first` or `updated`. +- File plugins that fail initially are retried once after waiting for config dependency installation. +- Runtime add uses the same external loader path, including the file-plugin retry after dependency wait. +- Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded. +- Runtime install and runtime add are separate operations. +- Plugin init failure rolls back that plugin's tracked registrations and loading continues. +- TUI runtime tracks and disposes: + - command registrations + - route registrations + - event subscriptions + - slot registrations + - explicit `lifecycle.onDispose(...)` handlers +- Cleanup runs in reverse order. +- Cleanup is awaited. +- Total cleanup budget per plugin is 5 seconds; timeout/error is logged and shutdown continues. + +## Built-in plugins + +- `internal:home-tips` +- `internal:sidebar-context` +- `internal:sidebar-mcp` +- `internal:sidebar-lsp` +- `internal:sidebar-todo` +- `internal:sidebar-files` +- `internal:sidebar-footer` +- `internal:plugin-manager` + +Sidebar content order is currently: context `100`, mcp `200`, lsp `300`, todo `400`, files `500`. + +The plugin manager is exposed as a command with title `Plugins` and value `plugins.list`. + +- Keybind name is `plugin_manager`. +- Default keybind is `none`. +- It lists both internal and external plugins. +- It toggles based on `active`. +- Its own row is disabled only inside the manager dialog. +- It also exposes command `plugins.install` with title `Install plugin`. +- Inside the Plugins dialog, key `shift+i` opens the install prompt. +- Install prompt asks for npm package name. +- Scope defaults to local, and `tab` toggles local/global. +- Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`. +- Manager install uses `api.plugins.install(spec, { global })`. +- If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load. +- `tui` target detection includes `exports["./tui"]` and valid `oc-themes`. +- If install reports `tui=true`, manager then calls `api.plugins.add(spec)`. +- If runtime add fails, TUI shows a warning and restart remains the fallback. + +## Current in-repo examples + +- Local smoke plugin: `.opencode/plugins/tui-smoke.tsx` +- Local vim plugin: `.opencode/plugins/tui-vim.tsx` +- Local smoke config: `.opencode/tui.json` +- Local smoke theme: `.opencode/plugins/smoke-theme.json` diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb21f878f84bc75d1a12c7d3d68864d2850ab281 --- /dev/null +++ b/packages/opencode/src/account/account.ts @@ -0,0 +1,481 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { + FetchHttpClient, + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http" + +import { withTransientReadRetry } from "@/util/effect-http-client" +import { AccountRepo, type AccountRow } from "./repo" +import { normalizeServerUrl } from "./url" +import { + type AccountError, + AccessToken, + AccountID, + DeviceCode, + Info, + RefreshToken, + AccountServiceError, + AccountTransportError, + Login, + Org, + OrgID, + PollDenied, + PollError, + PollExpired, + PollPending, + type PollResult, + PollSlow, + PollSuccess, + UserCode, +} from "./schema" + +export { + AccountID, + type AccountError, + AccountRepoError, + AccountServiceError, + AccountTransportError, + AccessToken, + RefreshToken, + DeviceCode, + UserCode, + Info, + Org, + OrgID, + Login, + PollSuccess, + PollPending, + PollSlow, + PollExpired, + PollDenied, + PollError, + PollResult, +} from "./schema" + +export type AccountOrgs = { + account: Info + orgs: readonly Org[] +} + +export type ActiveOrg = { + account: Info + org: Org +} + +class RemoteConfig extends Schema.Class("RemoteConfig")({ + config: Schema.Record(Schema.String, Schema.Json), +}) {} + +const DurationFromSeconds = Schema.Number.pipe( + Schema.decodeTo(Schema.Duration, { + decode: SchemaGetter.transform((n) => Duration.seconds(n)), + encode: SchemaGetter.transform((d) => Duration.toSeconds(d)), + }), +) + +class TokenRefresh extends Schema.Class("TokenRefresh")({ + access_token: AccessToken, + refresh_token: RefreshToken, + expires_in: DurationFromSeconds, +}) {} + +class DeviceAuth extends Schema.Class("DeviceAuth")({ + device_code: DeviceCode, + user_code: UserCode, + verification_uri_complete: Schema.String, + expires_in: DurationFromSeconds, + interval: DurationFromSeconds, +}) {} + +class DeviceTokenSuccess extends Schema.Class("DeviceTokenSuccess")({ + access_token: AccessToken, + refresh_token: RefreshToken, + token_type: Schema.Literal("Bearer"), + expires_in: DurationFromSeconds, +}) {} + +class DeviceTokenError extends Schema.Class("DeviceTokenError")({ + error: Schema.String, + error_description: Schema.String, +}) { + toPollResult(): PollResult { + if (this.error === "authorization_pending") return new PollPending() + if (this.error === "slow_down") return new PollSlow() + if (this.error === "expired_token") return new PollExpired() + if (this.error === "access_denied") return new PollDenied() + return new PollError({ cause: this.error }) + } +} + +const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError]) + +class User extends Schema.Class("User")({ + id: AccountID, + email: Schema.String, +}) {} + +class ClientId extends Schema.Class("ClientId")({ client_id: Schema.String }) {} + +class DeviceTokenRequest extends Schema.Class("DeviceTokenRequest")({ + grant_type: Schema.String, + device_code: DeviceCode, + client_id: Schema.String, +}) {} + +class TokenRefreshRequest extends Schema.Class("TokenRefreshRequest")({ + grant_type: Schema.String, + refresh_token: RefreshToken, + client_id: Schema.String, +}) {} + +const clientId = "opencode-cli" +const eagerRefreshThreshold = Duration.minutes(5) +const eagerRefreshThresholdMs = Duration.toMillis(eagerRefreshThreshold) + +const isTokenFresh = (tokenExpiry: number | null, now: number) => + tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs + +const mapAccountServiceError = + (message = "Account service operation failed") => + (effect: Effect.Effect): Effect.Effect => + effect.pipe(Effect.mapError((cause) => accountErrorFromCause(cause, message))) + +const accountErrorFromCause = (cause: unknown, message: string): AccountError => { + if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) { + return cause + } + + if (HttpClientError.isHttpClientError(cause)) { + switch (cause.reason._tag) { + case "TransportError": { + return AccountTransportError.fromHttpClientError(cause.reason) + } + default: { + return new AccountServiceError({ message, cause }) + } + } + } + + return new AccountServiceError({ message, cause }) +} + +export interface Interface { + readonly active: () => Effect.Effect, AccountError> + readonly activeOrg: () => Effect.Effect, AccountError> + readonly list: () => Effect.Effect + readonly orgsByAccount: () => Effect.Effect + readonly remove: (accountID: AccountID) => Effect.Effect + readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect + readonly orgs: (accountID: AccountID) => Effect.Effect + readonly config: ( + accountID: AccountID, + orgID: OrgID, + ) => Effect.Effect>, AccountError> + readonly token: (accountID: AccountID) => Effect.Effect, AccountError> + readonly login: (url: string) => Effect.Effect + readonly poll: (input: Login) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Account") {} + +export const use = serviceUse(Service) + +const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const repo = yield* AccountRepo.Service + const http = yield* HttpClient.HttpClient + const httpRead = withTransientReadRetry(http) + const httpOk = HttpClient.filterStatusOk(http) + const httpReadOk = HttpClient.filterStatusOk(httpRead) + + const executeRead = (request: HttpClientRequest.HttpClientRequest) => + httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed")) + + const executeReadOk = (request: HttpClientRequest.HttpClientRequest) => + httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed")) + + const executeEffectOk = (request: Effect.Effect) => + request.pipe( + Effect.flatMap((req) => httpOk.execute(req)), + mapAccountServiceError("HTTP request failed"), + ) + + const executeEffect = (request: Effect.Effect) => + request.pipe( + Effect.flatMap((req) => http.execute(req)), + mapAccountServiceError("HTTP request failed"), + ) + + const refreshToken = Effect.fnUntraced(function* (row: AccountRow) { + const now = yield* Clock.currentTimeMillis + + const response = yield* executeEffectOk( + HttpClientRequest.post(`${row.url}/auth/device/token`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(TokenRefreshRequest)( + new TokenRefreshRequest({ + grant_type: "refresh_token", + refresh_token: row.refresh_token, + client_id: clientId, + }), + ), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + + const expiry = Option.some(now + Duration.toMillis(parsed.expires_in)) + + yield* repo.persistToken({ + accountID: row.id, + accessToken: parsed.access_token, + refreshToken: parsed.refresh_token, + expiry, + }) + + return parsed.access_token + }) + + const refreshTokenCache = yield* Cache.make({ + capacity: Number.POSITIVE_INFINITY, + timeToLive: Duration.zero, + lookup: Effect.fnUntraced(function* (accountID) { + const maybeAccount = yield* repo.getRow(accountID) + if (Option.isNone(maybeAccount)) { + return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" })) + } + + const account = maybeAccount.value + const now = yield* Clock.currentTimeMillis + if (isTokenFresh(account.token_expiry, now)) { + return account.access_token + } + + return yield* refreshToken(account) + }), + }) + + const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { + const now = yield* Clock.currentTimeMillis + if (isTokenFresh(row.token_expiry, now)) { + return row.access_token + } + + return yield* Cache.get(refreshTokenCache, row.id) + }) + + const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) { + const maybeAccount = yield* repo.getRow(accountID) + if (Option.isNone(maybeAccount)) return Option.none() + + const account = maybeAccount.value + const accessToken = yield* resolveToken(account) + return Option.some({ account, accessToken }) + }) + + const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const response = yield* executeReadOk( + HttpClientRequest.get(`${url}/api/orgs`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + ), + ) + + return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + }) + + const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const response = yield* executeReadOk( + HttpClientRequest.get(`${url}/api/user`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + ), + ) + + return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + }) + + const token = Effect.fn("Account.token")((accountID: AccountID) => + resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))), + ) + + const activeOrg = Effect.fn("Account.activeOrg")(function* () { + const activeAccount = yield* repo.active() + if (Option.isNone(activeAccount)) return Option.none() + + const account = activeAccount.value + if (!account.active_org_id) return Option.none() + + const accountOrgs = yield* orgs(account.id) + const org = accountOrgs.find((item) => item.id === account.active_org_id) + if (!org) return Option.none() + + return Option.some({ account, org }) + }) + + const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () { + const accounts = yield* repo.list() + return yield* Effect.forEach( + accounts, + (account) => + orgs(account.id).pipe( + Effect.catch(() => Effect.succeed([] as readonly Org[])), + Effect.map((orgs) => ({ account, orgs })), + ), + { concurrency: 3 }, + ) + }) + + const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) { + const resolved = yield* resolveAccess(accountID) + if (Option.isNone(resolved)) return [] + + const { account, accessToken } = resolved.value + + return yield* fetchOrgs(account.url, accessToken) + }) + + const remove = Effect.fn("Account.remove")(function* (accountID: AccountID) { + const active = yield* repo.active() + yield* repo.remove(accountID) + if (Option.isNone(active) || active.value.id !== accountID) return + + const next = (yield* orgsByAccount()).flatMap((group) => + group.orgs.map((org) => ({ accountID: group.account.id, orgID: org.id })), + )[0] + if (!next) return + yield* repo.use(next.accountID, Option.some(next.orgID)) + }) + + const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) { + const resolved = yield* resolveAccess(accountID) + if (Option.isNone(resolved)) return Option.none() + + const { account, accessToken } = resolved.value + + const response = yield* executeRead( + HttpClientRequest.get(`${account.url}/api/config`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + HttpClientRequest.setHeaders({ "x-org-id": orgID }), + ), + ) + + if (response.status === 404) return Option.none() + + const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError()) + + const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe( + mapAccountServiceError("Failed to decode response"), + ) + return Option.some(parsed.config) + }) + + const login = Effect.fn("Account.login")(function* (server: string) { + const normalizedServer = normalizeServerUrl(server) + const response = yield* executeEffectOk( + HttpClientRequest.post(`${normalizedServer}/auth/device/code`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + const verification = yield* Effect.try({ + try: () => { + const url = new URL(parsed.verification_uri_complete, `${normalizedServer}/`) + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)") + return url.href + }, + catch: (cause) => new AccountServiceError({ message: "Invalid device verification URL", cause }), + }) + return new Login({ + code: parsed.device_code, + user: parsed.user_code, + url: verification, + server: normalizedServer, + expiry: parsed.expires_in, + interval: parsed.interval, + }) + }) + + const poll = Effect.fn("Account.poll")(function* (input: Login) { + const response = yield* executeEffect( + HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(DeviceTokenRequest)( + new DeviceTokenRequest({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: input.code, + client_id: clientId, + }), + ), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + + if (parsed instanceof DeviceTokenError) return parsed.toPollResult() + const accessToken = parsed.access_token + + const user = fetchUser(input.server, accessToken) + const orgs = fetchOrgs(input.server, accessToken) + + const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 }) + + // TODO: When there are multiple orgs, let the user choose + const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none() + + const now = yield* Clock.currentTimeMillis + const expiry = now + Duration.toMillis(parsed.expires_in) + const refreshToken = parsed.refresh_token + + yield* repo.persistAccount({ + id: account.id, + email: account.email, + url: input.server, + accessToken, + refreshToken, + expiry, + orgID: firstOrgID, + }) + + return new PollSuccess({ email: account.email }) + }) + + return Service.of({ + active: repo.active, + activeOrg, + list: repo.list, + orgsByAccount, + remove, + use: repo.use, + orgs, + config, + token, + login, + poll, + }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [AccountRepo.node, httpClient] }) + +export * as Account from "./account" diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts new file mode 100644 index 0000000000000000000000000000000000000000..62fac1e618d031579ba92e42148df7237276c913 --- /dev/null +++ b/packages/opencode/src/account/repo.ts @@ -0,0 +1,171 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { eq } from "drizzle-orm" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { Effect, Layer, Option, Schema, Context } from "effect" + +import { Database } from "@opencode-ai/core/database/database" +import { AccountStateTable, AccountTable } from "@opencode-ai/core/account/sql" +import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema" +import { normalizeServerUrl } from "./url" + +export type AccountRow = (typeof AccountTable)["$inferSelect"] + +const ACCOUNT_STATE_ID = 1 + +export interface Interface { + readonly active: () => Effect.Effect, AccountRepoError> + readonly list: () => Effect.Effect + readonly remove: (accountID: AccountID) => Effect.Effect + readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect + readonly getRow: (accountID: AccountID) => Effect.Effect, AccountRepoError> + readonly persistToken: (input: { + accountID: AccountID + accessToken: AccessToken + refreshToken: RefreshToken + expiry: Option.Option + }) => Effect.Effect + readonly persistAccount: (input: { + id: AccountID + email: string + url: string + accessToken: AccessToken + refreshToken: RefreshToken + expiry: number + orgID: Option.Option + }) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/AccountRepo") {} + +export const use = serviceUse(Service) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + const decode = Schema.decodeUnknownSync(Info) + + const query = (effect: Effect.Effect) => + effect.pipe(Effect.mapError((cause) => new AccountRepoError({ message: "Database operation failed", cause }))) + + const current = Effect.fnUntraced(function* () { + const state = yield* db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get() + if (!state?.active_account_id) return + const account = yield* db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get() + if (!account) return + return { ...account, active_org_id: state.active_org_id ?? null } + }) + + const state = (accountID: AccountID, orgID: Option.Option) => { + const id = Option.getOrNull(orgID) + return db + .insert(AccountStateTable) + .values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id }) + .onConflictDoUpdate({ + target: AccountStateTable.id, + set: { active_account_id: accountID, active_org_id: id }, + }) + .run() + } + + const active = Effect.fn("AccountRepo.active")(() => + query(current()).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))), + ) + + const list = Effect.fn("AccountRepo.list")(() => + query( + db + .select() + .from(AccountTable) + .all() + .pipe(Effect.map((rows) => rows.map((row: AccountRow) => decode({ ...row, active_org_id: null })))), + ), + ) + + const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) => + query( + db.transaction((tx) => + Effect.gen(function* () { + yield* tx + .update(AccountStateTable) + .set({ active_account_id: null, active_org_id: null }) + .where(eq(AccountStateTable.active_account_id, accountID)) + .run() + yield* tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run() + }), + ), + ).pipe(Effect.asVoid), + ) + + const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option) => + query(state(accountID, orgID)).pipe(Effect.asVoid), + ) + + const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) => + query(db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe( + Effect.map(Option.fromNullishOr), + ), + ) + + const persistToken = Effect.fn("AccountRepo.persistToken")((input) => + query( + db + .update(AccountTable) + .set({ + access_token: input.accessToken, + refresh_token: input.refreshToken, + token_expiry: Option.getOrNull(input.expiry), + }) + .where(eq(AccountTable.id, input.accountID)) + .run(), + ).pipe(Effect.asVoid), + ) + + const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) => + query( + db.transaction((tx) => + Effect.gen(function* () { + const url = normalizeServerUrl(input.url) + + yield* tx + .insert(AccountTable) + .values({ + id: input.id, + email: input.email, + url, + access_token: input.accessToken, + refresh_token: input.refreshToken, + token_expiry: input.expiry, + }) + .onConflictDoUpdate({ + target: AccountTable.id, + set: { + email: input.email, + url, + access_token: input.accessToken, + refresh_token: input.refreshToken, + token_expiry: input.expiry, + }, + }) + .run() + yield* state(input.id, input.orgID) + }), + ), + ).pipe(Effect.asVoid), + ) + + return Service.of({ + active, + list, + remove, + use, + getRow, + persistToken, + persistAccount, + }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] }) + +export * as AccountRepo from "./repo" diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c008435ab541f2b53593ba205d794b18d29f95f --- /dev/null +++ b/packages/opencode/src/account/schema.ts @@ -0,0 +1,99 @@ +import { Schema } from "effect" +import type * as HttpClientError from "effect/unstable/http/HttpClientError" + +export const AccountID = Schema.String.pipe(Schema.brand("AccountID")) +export type AccountID = Schema.Schema.Type + +export const OrgID = Schema.String.pipe(Schema.brand("OrgID")) +export type OrgID = Schema.Schema.Type + +export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken")) +export type AccessToken = Schema.Schema.Type + +export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken")) +export type RefreshToken = Schema.Schema.Type + +export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode")) +export type DeviceCode = Schema.Schema.Type + +export const UserCode = Schema.String.pipe(Schema.brand("UserCode")) +export type UserCode = Schema.Schema.Type + +export class Info extends Schema.Class("Account")({ + id: AccountID, + email: Schema.String, + url: Schema.String, + active_org_id: Schema.NullOr(OrgID), +}) {} + +export class Org extends Schema.Class("Org")({ + id: OrgID, + name: Schema.String, +}) {} + +export class AccountRepoError extends Schema.TaggedErrorClass()("AccountRepoError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +export class AccountServiceError extends Schema.TaggedErrorClass()("AccountServiceError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +export class AccountTransportError extends Schema.TaggedErrorClass()("AccountTransportError", { + method: Schema.String, + url: Schema.String, + description: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), +}) { + static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError { + return new AccountTransportError({ + method: error.request.method, + url: error.request.url, + description: error.description, + cause: error.cause, + }) + } + + override get message(): string { + return [ + `Could not reach ${this.method} ${this.url}.`, + `This failed before the server returned an HTTP response.`, + this.description, + `Check your network, proxy, or VPN configuration and try again.`, + ] + .filter(Boolean) + .join("\n") + } +} + +export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError + +export class Login extends Schema.Class("Login")({ + code: DeviceCode, + user: UserCode, + url: Schema.String, + server: Schema.String, + expiry: Schema.Duration, + interval: Schema.Duration, +}) {} + +export class PollSuccess extends Schema.TaggedClass()("PollSuccess", { + email: Schema.String, +}) {} + +export class PollPending extends Schema.TaggedClass()("PollPending", {}) {} + +export class PollSlow extends Schema.TaggedClass()("PollSlow", {}) {} + +export class PollExpired extends Schema.TaggedClass()("PollExpired", {}) {} + +export class PollDenied extends Schema.TaggedClass()("PollDenied", {}) {} + +export class PollError extends Schema.TaggedClass()("PollError", { + cause: Schema.Defect(), +}) {} + +export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError]) +export type PollResult = Schema.Schema.Type diff --git a/packages/opencode/src/account/url.ts b/packages/opencode/src/account/url.ts new file mode 100644 index 0000000000000000000000000000000000000000..36afd6d8e99b21f9cedb551704c4cce7c540f7a1 --- /dev/null +++ b/packages/opencode/src/account/url.ts @@ -0,0 +1,8 @@ +export const normalizeServerUrl = (input: string): string => { + const url = new URL(input) + url.search = "" + url.hash = "" + + const pathname = url.pathname.replace(/\/+$/, "") + return pathname.length === 0 ? url.origin : `${url.origin}${pathname}` +} diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7c59a2615519d6ebe5b237181bd5fadd8233b90 --- /dev/null +++ b/packages/opencode/src/acp/agent.ts @@ -0,0 +1,95 @@ +import { + RequestError, + type Agent as ACPAgent, + type AgentSideConnection, + type AuthenticateRequest, + type CancelNotification, + type CloseSessionRequest, + type ForkSessionRequest, + type InitializeRequest, + type ListSessionsRequest, + type LoadSessionRequest, + type NewSessionRequest, + type PromptRequest, + type ResumeSessionRequest, + type SetSessionConfigOptionRequest, + type SetSessionModelRequest, + type SetSessionModeRequest, +} from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import * as ACPError from "./error" +import * as ACPService from "./service" + +export function init({ sdk: _sdk }: { sdk: OpencodeClient }) { + return { + create: (connection: AgentSideConnection) => { + return new Agent(ACPService.make({ sdk: _sdk, connection })) + }, + } +} + +export class Agent implements ACPAgent { + constructor(private readonly service: ACPService.Interface) {} + + initialize(params: InitializeRequest) { + return run(this.service.initialize(params)) + } + + authenticate(params: AuthenticateRequest) { + return run(this.service.authenticate(params)) + } + + newSession(params: NewSessionRequest) { + return run(this.service.newSession(params)) + } + + loadSession(params: LoadSessionRequest) { + return run(this.service.loadSession(params)) + } + + listSessions(params: ListSessionsRequest) { + return run(this.service.listSessions(params)) + } + + resumeSession(params: ResumeSessionRequest) { + return run(this.service.resumeSession(params)) + } + + closeSession(params: CloseSessionRequest) { + return run(this.service.closeSession(params)) + } + + unstable_forkSession(params: ForkSessionRequest) { + return run(this.service.forkSession(params)) + } + + setSessionConfigOption(params: SetSessionConfigOptionRequest) { + return run(this.service.setSessionConfigOption(params)) + } + + setSessionMode(params: SetSessionModeRequest) { + return run(this.service.setSessionMode(params)) + } + + unstable_setSessionModel(params: SetSessionModelRequest) { + return run(this.service.setSessionModel(params)) + } + + prompt(params: PromptRequest) { + return run(this.service.prompt(params)) + } + + cancel(params: CancelNotification) { + return run(this.service.cancel(params)) + } +} + +function run(effect: Effect.Effect) { + return Effect.runPromise(effect.pipe(Effect.mapError(ACPError.toRequestError))).catch((defect: unknown) => { + if (defect instanceof RequestError) throw defect + throw ACPError.toRequestError(ACPError.fromUnknownDefect(defect)) + }) +} + +export * as ACP from "./agent" diff --git a/packages/opencode/src/acp/config-option.ts b/packages/opencode/src/acp/config-option.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebf8baf131888432a7371241dd5f804a4c270762 --- /dev/null +++ b/packages/opencode/src/acp/config-option.ts @@ -0,0 +1,206 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk" + +export const DEFAULT_VARIANT_VALUE = "default" + +export type ConfigOptionModel = { + id: string + name: string + variants?: Record> +} + +export type ConfigOptionProvider = { + id: string + name: string + models: Record +} + +export type ConfigOptionMode = { + id: string + name: string + description?: string +} + +export type ModelSelection = { + model: { + providerID: string + modelID: string + } + variant?: string +} + +export function buildModelSelectOption(input: { + providers: readonly ConfigOptionProvider[] + currentModel: ModelSelection["model"] + currentVariant?: string + includeVariants?: boolean +}): SessionConfigOption { + return { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: formatCurrentModelId({ + model: input.currentModel, + variant: input.currentVariant, + variants: variantsForModel(input.providers, input.currentModel), + includeVariant: input.includeVariants ?? false, + }), + options: buildModelSelectOptions(input.providers, { includeVariants: input.includeVariants ?? false }), + } +} + +export function buildEffortSelectOption(input: { + variants: readonly string[] + currentVariant?: string +}): SessionConfigOption | undefined { + if (input.variants.length === 0) return undefined + + return { + id: "effort", + name: "Effort", + description: "Available effort levels for this model", + category: "thought_level", + type: "select", + currentValue: + input.currentVariant === DEFAULT_VARIANT_VALUE + ? DEFAULT_VARIANT_VALUE + : selectVariant(input.currentVariant, input.variants), + options: [...new Set([...input.variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({ + value: variant, + name: formatVariantName(variant), + })), + } +} + +export function buildModeSelectOption(input: { + modes: readonly ConfigOptionMode[] + currentModeId: string +}): SessionConfigOption { + return { + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: input.currentModeId, + options: input.modes.map((mode) => ({ + value: mode.id, + name: mode.name, + ...(mode.description ? { description: mode.description } : {}), + })), + } +} + +export function buildConfigOptions(input: { + providers: readonly ConfigOptionProvider[] + currentModel: ModelSelection["model"] + currentVariant?: string + includeModelVariants?: boolean + modes?: readonly ConfigOptionMode[] + currentModeId?: string +}): SessionConfigOption[] { + const variants = variantsForModel(input.providers, input.currentModel) + const effort = buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) + + return [ + buildModelSelectOption({ + providers: input.providers, + currentModel: input.currentModel, + currentVariant: input.currentVariant, + includeVariants: input.includeModelVariants ?? false, + }), + ...(effort ? [effort] : []), + ...(input.modes && input.currentModeId + ? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })] + : []), + ] +} + +export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection { + const provider = providers.find((item) => modelId.startsWith(`${item.id}/`)) + if (provider) { + const modelID = modelId.slice(provider.id.length + 1) + if (provider.models[modelID]) { + return { model: { providerID: provider.id, modelID } } + } + + const separator = modelID.lastIndexOf("/") + if (separator > -1) { + const baseModelID = modelID.slice(0, separator) + const variant = modelID.slice(separator + 1) + if (provider.models[baseModelID]?.variants?.[variant]) { + return { model: { providerID: provider.id, modelID: baseModelID }, variant } + } + } + + return { model: { providerID: provider.id, modelID } } + } + + const separator = modelId.indexOf("/") + if (separator === -1) { + return { model: { providerID: modelId, modelID: "" } } + } + + return { + model: { + providerID: modelId.slice(0, separator), + modelID: modelId.slice(separator + 1), + }, + } +} + +export function formatCurrentModelId(input: { + model: ModelSelection["model"] + variant?: string + variants?: readonly string[] + includeVariant?: boolean +}) { + const base = `${input.model.providerID}/${input.model.modelID}` + if (!input.includeVariant || !input.variants?.length) return base + return `${base}/${selectVariant(input.variant, input.variants)}` +} + +export function formatVariantName(variant: string) { + return variant + .split(/[_-]/) + .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) + .join(" ") +} + +function buildModelSelectOptions( + providers: readonly ConfigOptionProvider[], + options: { includeVariants: boolean }, +): Array<{ value: string; name: string }> { + return providers.flatMap((provider) => + Object.values(provider.models) + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((model) => { + const base = { + value: `${provider.id}/${model.id}`, + name: `${provider.name}/${model.name}`, + } + if (!options.includeVariants || !model.variants) return [base] + + return [ + base, + ...Object.keys(model.variants) + .filter((variant) => variant !== DEFAULT_VARIANT_VALUE) + .map((variant) => ({ + value: `${provider.id}/${model.id}/${variant}`, + name: `${provider.name}/${model.name} (${formatVariantName(variant)})`, + })), + ] + }), + ) +} + +function variantsForModel(providers: readonly ConfigOptionProvider[], model: ModelSelection["model"]) { + return Object.keys( + providers.find((provider) => provider.id === model.providerID)?.models[model.modelID]?.variants ?? {}, + ) +} + +function selectVariant(variant: string | undefined, variants: readonly string[]) { + if (variant && variants.includes(variant)) return variant + if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE + return variants[0] +} diff --git a/packages/opencode/src/acp/content.ts b/packages/opencode/src/acp/content.ts new file mode 100644 index 0000000000000000000000000000000000000000..9207dd7c9dbbbbc0fb3dda628806651b42bb9ee9 --- /dev/null +++ b/packages/opencode/src/acp/content.ts @@ -0,0 +1,269 @@ +import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { SessionV1 } from "@opencode-ai/core/v1/session" + +export type PromptPart = SessionV1.TextPartInput | SessionV1.FilePartInput + +export type ReplayPart = + | { + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + } + | { + type: "file" + url: string + mime: string + filename?: string + } + | { + type: "reasoning" + text: string + } + +export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] { + return content.flatMap(contentBlockToParts) +} + +export function contentBlockToParts(block: ContentBlock): PromptPart[] { + switch (block.type) { + case "text": + return [ + { + type: "text", + text: block.text, + ...audienceFlags(block.annotations?.audience ?? undefined), + }, + ] + + case "image": + if (block.data) { + return [ + { + type: "file", + url: `data:${block.mimeType};base64,${block.data}`, + filename: filenameFromUri(block.uri ?? undefined) ?? "image", + mime: block.mimeType, + }, + ] + } + if (block.uri?.startsWith("data:")) { + return [ + { + type: "file", + url: block.uri, + filename: filenameFromUri(block.uri) ?? "image", + mime: block.mimeType, + }, + ] + } + if (block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) { + return [ + { + type: "file", + url: block.uri, + filename: filenameFromUri(block.uri) ?? "image", + mime: block.mimeType, + }, + ] + } + return [] + + case "resource_link": + return [resourceLinkToPart(block)] + + case "resource": + if ("text" in block.resource) { + try { + const parsed = new URL(block.resource.uri) + if (parsed.protocol === "file:") { + const line = parsed.hash.match(/^#L(\d+)/)?.[1] + let filepath: string + try { + filepath = fileURLToPath(parsed) + } catch { + filepath = decodeURIComponent(parsed.pathname) + } + if (path.sep === "\\") filepath = filepath.replace(/\\/g, "/") + return [ + { + type: "text", + text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}`, + }, + ] + } + } catch {} + return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }] + } + if (block.resource.mimeType) { + return [ + { + type: "file", + url: block.resource.uri.startsWith("data:") + ? block.resource.uri + : `data:${block.resource.mimeType};base64,${block.resource.blob}`, + filename: filenameFromUri(block.resource.uri) ?? "file", + mime: block.resource.mimeType, + }, + ] + } + return [] + + default: + return [] + } +} + +export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] { + return parts.flatMap(partToContentChunks) +} + +export function partToContentChunks(part: ReplayPart): ContentChunk[] { + switch (part.type) { + case "text": + if (!part.text) return [] + return [ + { + content: { + type: "text", + text: part.text, + ...partAudience(part), + }, + }, + ] + + case "file": + return filePartToContentChunks(part) + + case "reasoning": + if (!part.text) return [] + return [ + { + content: { + type: "text", + text: part.text, + }, + }, + ] + } +} + +function resourceLinkToPart(link: ResourceLink): PromptPart { + const parsed = uriToFilePart(link.uri, link.mimeType ?? "text/plain", link.name) + if (parsed.type === "file") return parsed + return { type: "text", text: parsed.text } +} + +function uriToFilePart( + uri: string, + mime: string, + filename?: string, +): SessionV1.FilePartInput | SessionV1.TextPartInput { + try { + if (uri.startsWith("file://")) { + return { + type: "file", + url: uri, + filename: filename ?? filenameFromUri(uri) ?? "file", + mime, + } + } + if (uri.startsWith("zed://")) { + const pathname = new URL(uri).searchParams.get("path") + if (pathname) { + return { + type: "file", + url: pathToFileURL(pathname).href, + filename: filename ?? (path.basename(pathname) || "file"), + mime, + } + } + } + return { type: "text", text: uri } + } catch { + return { type: "text", text: uri } + } +} + +function filePartToContentChunks(part: Extract): ContentChunk[] { + if (part.url.startsWith("file://")) { + return [ + { + content: { + type: "resource_link", + uri: part.url, + name: part.filename ?? "file", + mimeType: part.mime, + }, + }, + ] + } + if (!part.url.startsWith("data:")) return [] + + const data = decodeDataUrl(part.url) + if (!data) return [] + if (data.mime.startsWith("image/")) { + return [ + { + content: { + type: "image", + mimeType: data.mime, + data: data.base64, + uri: pathToFileURL(part.filename ?? "image").href, + }, + }, + ] + } + + return [ + { + content: { + type: "resource", + resource: + data.mime.startsWith("text/") || data.mime === "application/json" + ? { + uri: pathToFileURL(part.filename ?? "file").href, + mimeType: data.mime, + text: Buffer.from(data.base64, "base64").toString("utf8"), + } + : { + uri: pathToFileURL(part.filename ?? "file").href, + mimeType: data.mime, + blob: data.base64, + }, + }, + }, + ] +} + +function decodeDataUrl(url: string) { + const match = /^data:([^;]+);base64,(.*)$/.exec(url) + if (!match) return + return { mime: match[1], base64: match[2] } +} + +function audienceFlags(audience: readonly Role[] | null | undefined) { + if (audience?.length === 1 && audience[0] === "assistant") return { synthetic: true } + if (audience?.length === 1 && audience[0] === "user") return { ignored: true } + return {} +} + +function partAudience(part: Extract) { + const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined + if (!audience) return {} + return { annotations: { audience } } +} + +function filenameFromUri(uri: string | undefined) { + if (!uri) return + if (uri.startsWith("data:")) return + try { + const parsed = new URL(uri) + const name = path.basename(parsed.pathname) + return name || undefined + } catch { + return path.basename(uri) || undefined + } +} diff --git a/packages/opencode/src/acp/directory.ts b/packages/opencode/src/acp/directory.ts new file mode 100644 index 0000000000000000000000000000000000000000..2301c8929a88a1d4a544060f6b3b4a5dc2f99787 --- /dev/null +++ b/packages/opencode/src/acp/directory.ts @@ -0,0 +1,212 @@ +import { Agent } from "@/agent/agent" +import { Command } from "@/command" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { Provider } from "@/provider/provider" +import { Context, Effect, Layer, SynchronizedRef } from "effect" +import type * as ACPError from "./error" + +export type ModelOption = { + readonly providerID: ProviderV2.ID + readonly providerName: string + readonly modelID: ModelV2.ID + readonly modelName: string +} + +export type ModeOption = { + readonly id: string + readonly name: string + readonly description?: string +} + +export type ModelVariants = NonNullable + +export type DefaultModel = { + readonly providerID: ProviderV2.ID + readonly modelID: ModelV2.ID +} + +export type Snapshot = { + readonly directory: string + readonly providers: Record + readonly modelOptions: readonly ModelOption[] + readonly variantsByModel: Readonly> + readonly availableModes: readonly ModeOption[] + readonly defaultModeID: string + readonly availableCommands: readonly Command.Info[] + readonly defaultModel?: DefaultModel +} + +export interface LoaderInterface { + readonly load: (directory: string) => Effect.Effect +} + +export interface Interface { + readonly get: (directory: string) => Effect.Effect + readonly refresh: (directory: string) => Effect.Effect + readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined +} + +export class Loader extends Context.Service()("@opencode/ACPDirectoryLoader") {} + +export class Service extends Context.Service()("@opencode/ACPDirectory") {} + +export const modelKey = (model: DefaultModel) => `${model.providerID}/${model.modelID}` + +export const variants = (snapshot: Snapshot, model: DefaultModel) => snapshot.variantsByModel[modelKey(model)] + +export const build = (input: { + readonly directory: string + readonly providers: Record + readonly modes: readonly ModeOption[] + readonly defaultModeID: string + readonly commands: readonly Command.Info[] + readonly defaultModel?: DefaultModel +}): Snapshot => { + const modelOptions = Provider.sort( + Object.values(input.providers).flatMap((provider) => + Object.values(provider.models).map((model) => ({ + id: model.id, + providerID: provider.id, + providerName: provider.name, + modelID: model.id, + modelName: model.name, + })), + ), + ).map((model) => ({ + providerID: model.providerID, + providerName: model.providerName, + modelID: model.modelID, + modelName: model.modelName, + })) + + return { + directory: input.directory, + providers: input.providers, + modelOptions, + variantsByModel: Object.fromEntries( + Object.values(input.providers).flatMap((provider) => + Object.values(provider.models).flatMap((model) => + model.variants ? [[modelKey({ providerID: provider.id, modelID: model.id }), model.variants]] : [], + ), + ), + ), + availableModes: input.modes, + defaultModeID: input.modes.some((mode) => mode.id === input.defaultModeID) + ? input.defaultModeID + : (input.modes[0]?.id ?? input.defaultModeID), + availableCommands: input.commands, + ...(input.defaultModel ? { defaultModel: input.defaultModel } : {}), + } +} + +export const loaderLayer = Layer.effect( + Loader, + Effect.gen(function* () { + const store = yield* InstanceStore.Service + const provider = yield* Provider.Service + const agent = yield* Agent.Service + const command = yield* Command.Service + + return Loader.of({ + load: Effect.fn("ACPDirectoryLoader.load")(function* (directory) { + const ctx = yield* store.load({ directory }) + return yield* Effect.gen(function* () { + const providers = yield* provider.list() + const [agents, defaultAgent, commands, defaultModel] = yield* Effect.all( + [agent.list(), agent.defaultInfo(), command.list(), provider.defaultModel().pipe(Effect.option)], + { concurrency: "unbounded" }, + ) + return build({ + directory, + providers, + modes: agents + .filter((item) => item.mode !== "subagent" && item.hidden !== true) + .map((item) => ({ + id: item.name, + name: item.name, + ...(item.description ? { description: item.description } : {}), + })), + defaultModeID: defaultAgent.name, + commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), + ...(defaultModel._tag === "Some" ? { defaultModel: defaultModel.value } : {}), + }) + }).pipe(Effect.provideService(InstanceRef, ctx)) + }), + }) + }), +) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const loader = yield* Loader + const snapshots = yield* SynchronizedRef.make(new Map>()) + + const cached = Effect.fnUntraced(function* (directory: string) { + return yield* SynchronizedRef.modifyEffect( + snapshots, + Effect.fnUntraced(function* (items) { + const current = items.get(directory) + if (current) return [current, items] as const + const next = yield* Effect.cached( + loader.load(directory).pipe( + Effect.tapError(() => + SynchronizedRef.update(snapshots, (state) => { + const next = new Map(state) + next.delete(directory) + return next + }), + ), + ), + ) + return [next, new Map(items).set(directory, next)] as const + }), + ) + }) + + const get = Effect.fn("ACPDirectory.get")(function* (directory: string) { + return yield* yield* cached(directory) + }) + + const refresh = Effect.fn("ACPDirectory.refresh")(function* (directory: string) { + return yield* SynchronizedRef.modifyEffect( + snapshots, + Effect.fnUntraced(function* (items) { + const next = yield* Effect.cached( + loader.load(directory).pipe( + Effect.tapError(() => + SynchronizedRef.update(snapshots, (state) => { + const next = new Map(state) + next.delete(directory) + return next + }), + ), + ), + ) + return [next, new Map(items).set(directory, next)] as const + }), + ).pipe(Effect.flatten) + }) + + return Service.of({ + get, + refresh, + variants, + }) + }), +) + +export const loaderNode = LayerNode.make({ + service: Loader, + layer: loaderLayer, + deps: [Provider.node, Agent.node, Command.node, InstanceStore.node], +}) + +export const node = LayerNode.make({ service: Service, layer, deps: [loaderNode] }) + +export * as Directory from "./directory" diff --git a/packages/opencode/src/acp/error.ts b/packages/opencode/src/acp/error.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd7684db9ab82e106d607da0bc0c027430349344 --- /dev/null +++ b/packages/opencode/src/acp/error.ts @@ -0,0 +1,97 @@ +import { RequestError } from "@agentclientprotocol/sdk" +import { Schema } from "effect" + +export class SessionNotFoundError extends Schema.TaggedErrorClass()("ACPSessionNotFoundError", { + sessionId: Schema.String, +}) {} + +export class InvalidConfigOptionError extends Schema.TaggedErrorClass()( + "ACPInvalidConfigOptionError", + { + configId: Schema.String, + }, +) {} + +export class InvalidModelError extends Schema.TaggedErrorClass()("ACPInvalidModelError", { + modelId: Schema.String, + providerId: Schema.optional(Schema.String), +}) {} + +export class InvalidEffortError extends Schema.TaggedErrorClass()("ACPInvalidEffortError", { + effort: Schema.String, +}) {} + +export class InvalidModeError extends Schema.TaggedErrorClass()("ACPInvalidModeError", { + mode: Schema.String, +}) {} + +export class AuthRequiredError extends Schema.TaggedErrorClass()("ACPAuthRequiredError", { + providerId: Schema.optional(Schema.String), +}) {} + +export class UnknownAuthMethodError extends Schema.TaggedErrorClass()( + "ACPUnknownAuthMethodError", + { + methodId: Schema.String, + }, +) {} + +export class UnsupportedOperationError extends Schema.TaggedErrorClass()( + "ACPUnsupportedOperationError", + { + method: Schema.String, + }, +) {} + +export class ServiceFailureError extends Schema.TaggedErrorClass()("ACPServiceFailureError", { + safeMessage: Schema.String, + service: Schema.optional(Schema.String), + errorName: Schema.optional(Schema.String), +}) {} + +export type Error = + | SessionNotFoundError + | InvalidConfigOptionError + | InvalidModelError + | InvalidEffortError + | InvalidModeError + | AuthRequiredError + | UnknownAuthMethodError + | UnsupportedOperationError + | ServiceFailureError + +export function toRequestError(error: Error) { + switch (error._tag) { + case "ACPSessionNotFoundError": + return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`) + case "ACPInvalidConfigOptionError": + return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`) + case "ACPInvalidModelError": + return RequestError.invalidParams( + { providerId: error.providerId, modelId: error.modelId }, + `model not found: ${error.modelId}`, + ) + case "ACPInvalidEffortError": + return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`) + case "ACPInvalidModeError": + return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`) + case "ACPAuthRequiredError": + return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required") + case "ACPUnknownAuthMethodError": + return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`) + case "ACPUnsupportedOperationError": + return RequestError.methodNotFound(error.method) + case "ACPServiceFailureError": + return RequestError.internalError( + { + ...(error.service ? { service: error.service } : {}), + ...(error.errorName ? { errorName: error.errorName } : {}), + }, + error.safeMessage, + ) + } +} + +export function fromUnknownDefect(_defect: unknown, safeMessage = "Internal service failure") { + return new ServiceFailureError({ safeMessage }) +} diff --git a/packages/opencode/src/acp/event.ts b/packages/opencode/src/acp/event.ts new file mode 100644 index 0000000000000000000000000000000000000000..1180d3f5b0f55994bfe4f8dae8ce8ab735156eb7 --- /dev/null +++ b/packages/opencode/src/acp/event.ts @@ -0,0 +1,421 @@ +import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import type { + Event, + EventMessagePartDelta, + EventMessagePartUpdated, + OpencodeClient, + Part, + SessionMessageResponse, + ToolPart, +} from "@opencode-ai/sdk/v2" +import { Effect } from "effect" +import { ACPSession } from "./session" +import { ACPPermission } from "./permission" +import { partsToContentChunks, type ReplayPart } from "./content" +import { + duplicateRunningToolUpdate, + errorToolUpdate, + pendingToolCall, + runningToolUpdate, + shellOutputSnapshot, + completedToolUpdate, +} from "./tool" + +type Connection = Pick & + Partial> +type GlobalEventEnvelope = { + payload?: Event +} +type GlobalEventStream = { + stream: AsyncIterable +} + +export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) { + const subscription = new Subscription(input) + subscription.start() + return subscription +} + +export class Subscription { + private readonly abort = new AbortController() + private readonly shellSnapshots = new Map() + private readonly toolStarts = new Set() + private readonly connectionWaiters = new Set<() => void>() + private readonly idleWaiters = new Map>>() + private readonly permission: ACPPermission.Handler + private connected = false + private started = false + + constructor( + private readonly input: { + sdk: OpencodeClient + connection: Connection + session: ACPSession.Interface + }, + ) { + this.permission = new ACPPermission.Handler(input) + } + + start() { + if (this.started) return + this.started = true + this.run().catch(() => { + if (this.abort.signal.aborted) return + }) + } + + stop() { + this.abort.abort() + this.disconnected() + for (const resolve of this.connectionWaiters) resolve() + this.connectionWaiters.clear() + } + + async runUntilIdle(sessionId: string, request: () => Promise) { + await this.waitUntilConnected() + const waiter = signal() + const waiters = this.idleWaiters.get(sessionId) ?? new Set() + waiters.add(waiter) + this.idleWaiters.set(sessionId, waiters) + + try { + // Idle is queued after the turn's events, and this subscription awaits each update in order. + void waiter.promise.catch(() => {}) + const response = await request() + await waiter.promise + return response + } finally { + waiters.delete(waiter) + if (waiters.size === 0) this.idleWaiters.delete(sessionId) + } + } + + async handle(event: Event) { + switch (event.type) { + case "session.status": + if (event.properties.status.type === "idle") this.idle(event.properties.sessionID) + return + case "permission.asked": + this.permission.handle(event) + return + case "message.part.updated": + return this.handlePartUpdated(event) + case "message.part.delta": + return this.handlePartDelta(event) + } + } + + async replayMessage(message: SessionMessageResponse) { + if (message.info.role !== "assistant" && message.info.role !== "user") return + + const cwd = message.info.role === "assistant" ? message.info.path?.cwd : undefined + for (const part of message.parts) { + await this.recordFetchedPart(message.info.sessionID, message, part) + if (part.type === "tool") { + await this.handleToolPart(message.info.sessionID, part, cwd ?? process.cwd()) + continue + } + await this.replayContentPart(message, part) + } + } + + private async replayContentPart(message: SessionMessageResponse, part: Part) { + if (part.type !== "text" && part.type !== "file" && part.type !== "reasoning") return + + const sessionUpdate = + part.type === "reasoning" + ? "agent_thought_chunk" + : message.info.role === "user" + ? "user_message_chunk" + : "agent_message_chunk" + + for (const chunk of partsToContentChunks([part as ReplayPart])) { + await this.input.connection.sessionUpdate({ + sessionId: message.info.sessionID, + update: { + sessionUpdate, + messageId: part.type === "reasoning" ? part.id : message.info.id, + ...chunk, + }, + }) + } + } + + private async run() { + while (!this.abort.signal.aborted) { + await this.consume().catch(() => {}) + this.disconnected() + if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + private async consume() { + const events = (await this.input.sdk.global.event({ + signal: this.abort.signal, + })) as GlobalEventStream + this.connected = true + for (const resolve of this.connectionWaiters) resolve() + this.connectionWaiters.clear() + + for await (const event of events.stream) { + if (this.abort.signal.aborted) return + if (!event.payload) continue + await this.handle(event.payload).catch(() => {}) + } + } + + private async waitUntilConnected() { + while (!this.connected) { + if (this.abort.signal.aborted) throw new Error("ACP event subscription stopped") + await new Promise((resolve) => this.connectionWaiters.add(resolve)) + } + } + + private disconnected() { + if (!this.connected) return + this.connected = false + const error = new Error("ACP event stream disconnected") + for (const waiters of this.idleWaiters.values()) { + for (const waiter of waiters) waiter.reject(error) + } + this.idleWaiters.clear() + } + + private idle(sessionId: string) { + const waiters = this.idleWaiters.get(sessionId) + if (!waiters) return + this.idleWaiters.delete(sessionId) + for (const waiter of waiters) waiter.resolve() + } + + private async handlePartUpdated(event: EventMessagePartUpdated) { + const part = event.properties.part + const sessionId = part.sessionID || event.properties.sessionID + const session = await Effect.runPromise(this.input.session.tryGet(sessionId)) + if (!session) return + + await Effect.runPromise( + this.input.session.recordPartMetadata({ + sessionId: session.id, + messageId: part.messageID, + partId: part.id, + partType: part.type, + role: part.type === "reasoning" ? "assistant" : undefined, + ignored: part.type === "text" ? part.ignored : undefined, + toolCallId: part.type === "tool" ? part.callID : undefined, + metadata: "metadata" in part ? part.metadata : undefined, + }), + ) + if (part.type === "tool") { + await this.handleToolPart(session.id, part, session.cwd) + } + } + + private async handlePartDelta(event: EventMessagePartDelta) { + const props = event.properties + const session = await Effect.runPromise(this.input.session.tryGet(props.sessionID)) + if (!session) return + + const known = await Effect.runPromise( + this.input.session.tryGetPartMetadata({ + sessionId: session.id, + messageId: props.messageID, + partId: props.partID, + }), + ) + const metadata = + known?.role && known.partType + ? known + : await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID) + if (metadata?.role !== "assistant") return + if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) { + await this.input.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "agent_message_chunk", + messageId: props.messageID, + content: { + type: "text", + text: props.delta, + }, + }, + }) + return + } + + if (metadata.partType === "reasoning" && props.field === "text") { + await this.input.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "agent_thought_chunk", + messageId: props.partID, + content: { + type: "text", + text: props.delta, + }, + }, + }) + } + } + + private async fetchPartMetadata(sessionId: string, cwd: string, messageId: string, partId: string) { + const message = await this.input.sdk.session + .message( + { + sessionID: sessionId, + messageID: messageId, + directory: cwd, + }, + { throwOnError: true }, + ) + .then((response) => response.data) + .catch(() => undefined) + if (!message) return + + const part = message.parts.find((item) => item.id === partId) + if (!part) return + return await this.recordFetchedPart(sessionId, message, part) + } + + private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) { + return await Effect.runPromise( + this.input.session.recordPartMetadata({ + sessionId, + messageId: part.messageID, + partId: part.id, + partType: part.type, + role: message.info.role, + ignored: part.type === "text" ? part.ignored : undefined, + toolCallId: part.type === "tool" ? part.callID : undefined, + metadata: "metadata" in part ? part.metadata : undefined, + }), + ) + } + + private async handleToolPart(sessionId: string, part: ToolPart, cwd: string) { + await this.toolStart(sessionId, part, cwd) + + switch (part.state.status) { + case "pending": + this.shellSnapshots.delete(part.callID) + return + + case "running": + await this.runningTool(sessionId, part, cwd) + return + + case "completed": + this.clearTool(part.callID) + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + ...completedToolUpdate({ + toolCallId: part.callID, + toolName: part.tool, + state: part.state, + cwd, + }), + }, + }) + return + + case "error": + this.clearTool(part.callID) + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + ...errorToolUpdate({ + toolCallId: part.callID, + toolName: part.tool, + state: part.state, + cwd, + }), + }, + }) + return + } + } + + private async runningTool(sessionId: string, part: ToolPart, cwd: string) { + if (part.state.status !== "running") return + + const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined + if (output !== undefined) { + if (this.shellSnapshots.get(part.callID) === output) { + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + ...duplicateRunningToolUpdate({ + toolCallId: part.callID, + toolName: part.tool, + state: part.state, + cwd, + }), + }, + }) + return + } + this.shellSnapshots.set(part.callID, output) + } + + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + ...runningToolUpdate({ + toolCallId: part.callID, + toolName: part.tool, + state: part.state, + output, + cwd, + }), + }, + }) + } + + private async toolStart(sessionId: string, part: ToolPart, cwd: string) { + if (this.toolStarts.has(part.callID)) return + this.toolStarts.add(part.callID) + await this.input.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + ...pendingToolCall({ + toolCallId: part.callID, + toolName: part.tool, + state: part.state, + cwd, + }), + }, + }) + } + + private clearTool(toolCallId: string) { + this.toolStarts.delete(toolCallId) + this.shellSnapshots.delete(toolCallId) + } +} + +function signal() { + const state: { + resolve: () => void + reject: (reason?: unknown) => void + } = { + resolve: () => {}, + reject: () => {}, + } + const promise = new Promise((resolve, reject) => { + state.resolve = resolve + state.reject = reject + }) + return { + promise, + resolve: () => state.resolve(), + reject: (reason?: unknown) => state.reject(reason), + } +} + +export * as ACPEvent from "./event" diff --git a/packages/opencode/src/acp/permission.ts b/packages/opencode/src/acp/permission.ts new file mode 100644 index 0000000000000000000000000000000000000000..4eeca28f09f5215fcbddf65a8e370e3e60266d15 --- /dev/null +++ b/packages/opencode/src/acp/permission.ts @@ -0,0 +1,254 @@ +import type { + AgentSideConnection, + PermissionOption, + RequestPermissionResponse, + ToolCallContent, + ToolCallLocation, + ToolCallUpdate, +} from "@agentclientprotocol/sdk" +import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" +import { applyPatch } from "diff" +import { exists, readText } from "@/util/filesystem" +import type { ACPSession } from "./session" +import { pendingToolCall, toLocations, type ToolInput } from "./tool" +import { Effect } from "effect" + +type PermissionEvent = Extract +type Reply = "once" | "always" | "reject" +type Connection = Partial> + +const permissionOptions: PermissionOption[] = [ + { optionId: "once", kind: "allow_once", name: "Allow once" }, + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, +] + +export class Handler { + private readonly queues = new Map>() + + constructor( + private readonly input: { + sdk: OpencodeClient + connection: Connection + session: ACPSession.Interface + }, + ) {} + + handle(event: PermissionEvent) { + const permission = event.properties + const previous = this.queues.get(permission.sessionID) ?? Promise.resolve() + const next = previous + .then(() => this.process(event)) + .catch(() => {}) + .finally(() => { + if (this.queues.get(permission.sessionID) === next) { + this.queues.delete(permission.sessionID) + } + }) + this.queues.set(permission.sessionID, next) + } + + private async process(event: PermissionEvent) { + const permission = event.properties + const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID)) + if (!session) return + + if (!this.input.connection.requestPermission) { + await this.reply(permission.id, "reject", session.cwd) + return + } + + const result = await this.input.connection + .requestPermission({ + sessionId: permission.sessionID, + toolCall: await permissionToolCall({ + toolCallId: permission.tool?.callID ?? permission.id, + toolName: permission.permission, + input: permission.metadata, + }), + options: permissionOptions, + }) + .catch(async () => { + await this.reply(permission.id, "reject", session.cwd) + return undefined + }) + + if (!result) return + + const reply = selectedReply(result) + if (reply !== "once" && reply !== "always") { + await this.reply(permission.id, "reject", session.cwd) + return + } + + if (permission.permission === "edit") { + await this.writeProposedEdit(session.id, permission.metadata).catch(() => {}) + } + + await this.reply(permission.id, reply, session.cwd) + } + + private async reply(requestID: string, reply: Reply, directory: string) { + await this.input.sdk.permission.reply({ + requestID, + reply, + directory, + }) + } + + private async writeProposedEdit(sessionId: string, metadata: ToolInput) { + const filepath = stringValue(metadata.filepath) + const diff = stringValue(metadata.diff) + if (!filepath || !diff || !this.input.connection.writeTextFile) return + + const content = (await exists(filepath)) ? await readText(filepath) : "" + const next = applyPatch(content, diff) + if (next === false) { + return + } + + void this.input.connection.writeTextFile({ + sessionId, + path: filepath, + content: next, + }) + } +} + +async function permissionToolCall(input: { + readonly toolCallId: string + readonly toolName: string + readonly input: ToolInput +}): Promise { + const toolCall = pendingToolCall({ + toolCallId: input.toolCallId, + toolName: input.toolName, + state: { + input: input.input, + title: permissionTitle(input.toolName, input.input), + }, + }) + const content = await permissionContent(input.toolName, input.input) + return { + ...toolCall, + locations: permissionLocations(input.toolName, input.input), + ...(content.length ? { content } : {}), + } +} + +function permissionTitle(toolName: string, input: ToolInput) { + const tool = toolName.toLocaleLowerCase() + switch (tool) { + case "external_directory": + return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir) + + case "webfetch": + return stringValue(input.url) + + case "websearch": + return stringValue(input.query) + + case "grep": + case "glob": + return stringValue(input.pattern) + + case "read": + case "edit": + case "write": + return editTitle(input) + + default: + return undefined + } +} + +function editTitle(input: ToolInput) { + const files = fileMetadata(input) + if (files.length === 1) return files[0]?.relativePath ?? files[0]?.filePath + if (files.length > 1) return `${files.length} files` + return stringValue(input.filePath) ?? stringValue(input.filepath) ?? stringValue(input.path) +} + +function permissionLocations(toolName: string, input: ToolInput): ToolCallLocation[] { + const files = fileMetadata(input) + if (files.length) { + return Array.from( + new Set(files.flatMap((file) => [file.filePath, file.movePath].filter((path): path is string => !!path))), + (path) => ({ path }), + ) + } + return toLocations(toolName, input) +} + +async function permissionContent(toolName: string, input: ToolInput): Promise { + if (toolName.toLocaleLowerCase() !== "edit") return [] + + const files = fileMetadata(input) + if (files.length) return diffContentForFiles(files) + + const filepath = stringValue(input.filepath) ?? stringValue(input.filePath) + const diff = stringValue(input.diff) + if (!filepath || !diff) return [] + const content = await diffContentForPatch(filepath, diff) + return content ? [content] : [] +} + +async function diffContentForFiles(files: PermissionFileMetadata[]) { + const content = await Promise.all( + files.map(async (file) => { + if (!file.patch) return [] + const content = await diffContentForPatch(file.filePath, file.patch, file.movePath) + return content ? [content] : [] + }), + ) + return content.flat() +} + +async function diffContentForPatch(filepath: string, diff: string, displayPath = filepath) { + const content = (await exists(filepath)) ? await readText(filepath) : "" + const next = applyPatch(content, diff) + if (next === false) return undefined + return { + type: "diff" as const, + path: displayPath, + oldText: content, + newText: next, + } +} + +function selectedReply(result: RequestPermissionResponse): Reply { + if (result.outcome.outcome !== "selected") return "reject" + if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId + return "reject" +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : undefined +} + +type PermissionFileMetadata = { + readonly filePath: string + readonly relativePath?: string + readonly movePath?: string + readonly patch?: string +} + +function fileMetadata(input: ToolInput): PermissionFileMetadata[] { + if (!Array.isArray(input.files)) return [] + return input.files.flatMap((file): PermissionFileMetadata[] => { + if (!file || typeof file !== "object") return [] + const info = file as Record + const filePath = stringValue(info.filePath) + if (!filePath) return [] + return [ + { + filePath, + relativePath: stringValue(info.relativePath), + movePath: stringValue(info.movePath), + patch: stringValue(info.patch), + }, + ] + }) +} + +export * as ACPPermission from "./permission" diff --git a/packages/opencode/src/acp/profile.ts b/packages/opencode/src/acp/profile.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e728b6a1aad8377bd307322a458da1c0c3bb9c4 --- /dev/null +++ b/packages/opencode/src/acp/profile.ts @@ -0,0 +1,42 @@ +const enabled = process.env.OPENCODE_ACP_PROFILE === "1" +const started = performance.now() + +export function mark(name: string, fields?: Record) { + if (!enabled) return + write(`${name}.mark`, performance.now() - started, fields) +} + +export function duration( + name: string, + startedAt: number, + fields?: Record, +) { + if (!enabled) return + write(name, performance.now() - startedAt, fields) +} + +export async function measure( + name: string, + fn: () => Promise, + fields?: Record, +) { + if (!enabled) return fn() + const start = performance.now() + try { + return await fn() + } finally { + write(name, performance.now() - start, fields) + } +} + +function write(name: string, durationMs: number, fields?: Record) { + const extra = fields + ? Object.entries(fields) + .filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined) + .map(([key, value]) => `${key}=${value}`) + .join(" ") + : "" + console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`) +} + +export * as ACPProfile from "./profile" diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..c542e2b75320b656549740c293794103908bc16f --- /dev/null +++ b/packages/opencode/src/acp/service.ts @@ -0,0 +1,1226 @@ +import { + type AgentSideConnection, + type AuthenticateRequest, + type AuthenticateResponse, + type AuthMethod, + type CancelNotification, + type CloseSessionRequest, + type CloseSessionResponse, + type ForkSessionRequest, + type ForkSessionResponse, + type InitializeRequest, + type InitializeResponse, + type ListSessionsRequest, + type ListSessionsResponse, + type LoadSessionRequest, + type LoadSessionResponse, + type McpServer, + type NewSessionRequest, + type NewSessionResponse, + type PromptRequest, + type PromptResponse, + type ResumeSessionRequest, + type ResumeSessionResponse, + type SessionInfo, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type SetSessionModelRequest, + type SetSessionModelResponse, + type SetSessionModeRequest, + type SetSessionModeResponse, +} from "@agentclientprotocol/sdk" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import type { AssistantMessage, Message, OpencodeClient, Session, SessionMessageResponse } from "@opencode-ai/sdk/v2" +import { Context, Effect, Layer, ManagedRuntime } from "effect" +import * as ACPError from "./error" +import { buildConfigOptions, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option" +import { promptContentToParts } from "./content" +import { Directory } from "./directory" +import { ACPEvent } from "./event" +import { ACPSession } from "./session" +import { UsageService } from "./usage" +import { ACPProfile } from "./profile" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { Provider } from "@/provider/provider" +import type { Command } from "@/command" + +export const AuthMethodID = "opencode-login" + +export type Error = ACPError.Error +type ServiceConnection = Pick & + Partial> + +export type Interface = { + readonly initialize: (input: InitializeRequest) => Effect.Effect + readonly authenticate: (input: AuthenticateRequest) => Effect.Effect + readonly newSession: (input: NewSessionRequest) => Effect.Effect + readonly loadSession: (input: LoadSessionRequest) => Effect.Effect + readonly listSessions: (input: ListSessionsRequest) => Effect.Effect + readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect + readonly closeSession: (input: CloseSessionRequest) => Effect.Effect + readonly forkSession: (input: ForkSessionRequest) => Effect.Effect + readonly setSessionConfigOption: ( + input: SetSessionConfigOptionRequest, + ) => Effect.Effect + readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect + readonly setSessionModel: (input: SetSessionModelRequest) => Effect.Effect + readonly prompt: (input: PromptRequest) => Effect.Effect + readonly cancel: (input: CancelNotification) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ACP/Service") {} + +export function make(input: { + sdk: OpencodeClient + connection?: ServiceConnection + directory?: Directory.Interface + session?: ACPSession.Interface + usage?: UsageService.Interface + eventSubscription?: (subscription: ACPEvent.Subscription) => void +}): Interface { + const session = input.session ?? makeSessionService() + const directoryService = input.directory ?? makeDirectoryService(input.sdk) + const registeredMcp = new Map>() + const sessionSnapshots = new Map() + const events = input.connection + ? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session }) + : undefined + if (events) input.eventSubscription?.(events) + const runUntilIdle = (sessionId: string, fn: () => Promise) => + events ? events.runUntilIdle(sessionId, fn) : fn() + + const initialize = Effect.fn("ACP.initialize")(function* (params: InitializeRequest) { + const started = performance.now() + const authMethod: AuthMethod = { + description: "Run `opencode auth login` in the terminal", + name: "Login with opencode", + id: AuthMethodID, + } + + if (params.clientCapabilities?._meta?.["terminal-auth"] === true) { + authMethod._meta = { + "terminal-auth": { + command: "opencode", + args: ["auth", "login"], + label: "OpenCode Login", + }, + } + } + + const response = { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + mcpCapabilities: { + http: true, + sse: true, + }, + promptCapabilities: { + embeddedContext: true, + image: true, + }, + sessionCapabilities: { + close: {}, + fork: {}, + list: {}, + resume: {}, + }, + }, + authMethods: [authMethod], + agentInfo: { + name: "OpenCode", + version: InstallationVersion, + }, + } + ACPProfile.duration("acp.initialize", started) + return response + }) + + const authenticate = Effect.fn("ACP.authenticate")(function* (params: AuthenticateRequest) { + if (params.methodId !== AuthMethodID) { + return yield* new ACPError.UnknownAuthMethodError({ methodId: params.methodId }) + } + return {} + }) + + const directorySnapshot = Effect.fn("ACP.directorySnapshot")(function* (cwd: string) { + const started = performance.now() + const snapshot = yield* directoryService.get(cwd) + ACPProfile.duration("acp.directory.snapshot", started) + return snapshot + }) + + const configSnapshot = Effect.fn("ACP.configSnapshot")(function* (state: ACPSession.Info) { + const snapshot = sessionSnapshots.get(state.id) + if (snapshot) return snapshot + const loaded = yield* directorySnapshot(state.cwd) + sessionSnapshots.set(state.id, loaded) + return loaded + }) + + const newSession = Effect.fn("ACP.newSession")(function* (params: NewSessionRequest) { + const started = performance.now() + const snapshot = yield* directorySnapshot(params.cwd) + const selected = selectDefaultModel(snapshot) + const variant = selectVariant(snapshot, selected) + const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined + const created = yield* profiledRequest( + "acp.newSession.session.create", + () => + input.sdk.session.create( + { + directory: params.cwd, + ...(modeId ? { agent: modeId } : {}), + model: { + providerID: selected.providerID, + id: selected.modelID, + ...(variant ? { variant } : {}), + }, + }, + { throwOnError: true }, + ), + "session", + ) + const state = yield* session.create({ + id: created.id, + cwd: params.cwd, + mcpServers: params.mcpServers, + model: selected, + variant, + modeId, + }) + sessionSnapshots.set(state.id, snapshot) + + yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) + yield* sendAvailableCommands(input.connection, state.id, snapshot) + + const response = { + sessionId: state.id, + configOptions: configOptions(snapshot, { + model: state.model ?? selected, + variant: state.variant, + modeId: state.modeId, + }), + } + ACPProfile.duration("acp.newSession", started) + return response + }) + + const loadSession = Effect.fn("ACP.loadSession")(function* (params: LoadSessionRequest) { + const snapshot = yield* directorySnapshot(params.cwd) + const backing = yield* request( + () => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), + "session", + ) + const messages = yield* request( + () => input.sdk.session.messages({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), + "session", + ) + const restored = restoreSession( + snapshot, + backing, + messages.map((item) => item.info), + ) + const state = yield* session.load({ + id: params.sessionId, + cwd: params.cwd, + mcpServers: params.mcpServers, + model: restored.model, + variant: restored.variant, + modeId: restored.modeId, + }) + sessionSnapshots.set(state.id, snapshot) + + yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) + yield* sendAvailableCommands(input.connection, state.id, snapshot) + yield* replayMessages(events, messages) + + return { + configOptions: configOptions(snapshot, { + model: state.model ?? restored.model, + variant: state.variant, + modeId: state.modeId, + }), + } + }) + + const listSessions = Effect.fn("ACP.listSessions")(function* (params: ListSessionsRequest) { + const cursor = params.cursor ? Number(params.cursor) : undefined + const limit = 100 + const sessions = yield* request( + () => + input.sdk.session.list( + { + ...(params.cwd ? { directory: params.cwd } : {}), + roots: true, + }, + { throwOnError: true }, + ), + "session", + ) + const serverEntries = sessions.map( + (item): SessionInfo => ({ + sessionId: item.id, + cwd: item.directory, + title: item.title, + updatedAt: new Date(item.time.updated).toISOString(), + }), + ) + const liveEntries = (yield* session.list(params.cwd ?? undefined)) + .filter((item) => !serverEntries.some((entry) => entry.sessionId === item.id)) + .map( + (item): SessionInfo => ({ + sessionId: item.id, + cwd: item.cwd, + updatedAt: item.createdAt.toISOString(), + }), + ) + const sorted = [...liveEntries, ...serverEntries].toSorted( + (a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime(), + ) + const filtered = + cursor === undefined || !Number.isFinite(cursor) + ? sorted + : sorted.filter((item) => new Date(item.updatedAt ?? 0).getTime() < cursor) + const page = filtered.slice(0, limit) + const last = page.at(-1) + return { + sessions: page, + ...(filtered.length > limit && last ? { nextCursor: String(new Date(last.updatedAt ?? 0).getTime()) } : {}), + } + }) + + const resumeSession = Effect.fn("ACP.resumeSession")(function* (params: ResumeSessionRequest) { + const snapshot = yield* directorySnapshot(params.cwd) + const backing = yield* request( + () => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), + "session", + ) + const messages = yield* request( + () => + input.sdk.session.messages( + { directory: params.cwd, sessionID: params.sessionId, limit: 20 }, + { throwOnError: true }, + ), + "session", + ) + const restored = restoreSession( + snapshot, + backing, + messages.map((item) => item.info), + ) + const state = yield* session.load({ + id: params.sessionId, + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + model: restored.model, + variant: restored.variant, + modeId: restored.modeId, + }) + sessionSnapshots.set(state.id, snapshot) + + yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) + yield* sendAvailableCommands(input.connection, state.id, snapshot) + + return { + configOptions: configOptions(snapshot, { + model: state.model ?? restored.model, + variant: state.variant, + modeId: state.modeId, + }), + } + }) + + const abortBackingSession = Effect.fn("ACP.abortBackingSession")(function* (current: ACPSession.Info) { + yield* request( + () => input.sdk.session.abort({ directory: current.cwd, sessionID: current.id }, { throwOnError: true }), + "session", + ).pipe( + Effect.catch((error) => + Effect.logError("failed to abort ACP backing session", { error: error, sessionID: current.id }), + ), + ) + }) + + const closeSession = Effect.fn("ACP.closeSession")(function* (params: CloseSessionRequest) { + const removed = yield* session.remove(params.sessionId) + registeredMcp.delete(params.sessionId) + sessionSnapshots.delete(params.sessionId) + if (!removed) return {} + + yield* abortBackingSession(removed) + return {} + }) + + const cancel = Effect.fn("ACP.cancel")(function* (params: CancelNotification) { + const current = yield* session.get(params.sessionId) + yield* abortBackingSession(current) + }) + + const forkSession = Effect.fn("ACP.forkSession")(function* (params: ForkSessionRequest) { + const snapshot = yield* directorySnapshot(params.cwd) + const forked = yield* request( + () => + input.sdk.session.fork( + { + directory: params.cwd, + sessionID: params.sessionId, + }, + { throwOnError: true }, + ), + "session", + ) + const messages = yield* request( + () => + input.sdk.session.messages({ directory: params.cwd, sessionID: forked.id, limit: 20 }, { throwOnError: true }), + "session", + ) + const restored = restoreSession( + snapshot, + forked, + messages.map((item) => item.info), + ) + const state = yield* session.load({ + id: forked.id, + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + model: restored.model, + variant: restored.variant, + modeId: restored.modeId, + }) + sessionSnapshots.set(state.id, snapshot) + + yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? []) + yield* sendAvailableCommands(input.connection, state.id, snapshot) + yield* replayMessages(events, messages) + + return { + sessionId: state.id, + configOptions: configOptions(snapshot, { + model: state.model ?? restored.model, + variant: state.variant, + modeId: state.modeId, + }), + } + }) + + const setSessionConfigOption = Effect.fn("ACP.setSessionConfigOption")(function* ( + params: SetSessionConfigOptionRequest, + ) { + const current = yield* session.get(params.sessionId) + const snapshot = yield* configSnapshot(current) + if (typeof params.value !== "string") { + return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId }) + } + + if (params.configId === "model") { + const selected = yield* parseSelectedModel(snapshot, params.value) + const variant = selectModelVariant(snapshot, current, selected) + const state = yield* session + .setVariant(params.sessionId, Directory.variants(snapshot, selected.model) ? variant : undefined) + .pipe(Effect.andThen(session.setModel(params.sessionId, selected.model))) + const options = configOptions(snapshot, { + model: state.model ?? selected.model, + variant: state.variant, + modeId: state.modeId, + }) + yield* sendConfigOptionUpdate(input.connection, params.sessionId, options) + return { + configOptions: options, + } + } + + if (params.configId === "effort") { + const model = current.model ?? selectDefaultModel(snapshot) + const variants = Directory.variants(snapshot, model) + if (!variants || !hasVariant(variants, params.value)) { + return yield* new ACPError.InvalidEffortError({ effort: params.value }) + } + const state = yield* session.setVariant(params.sessionId, params.value) + return { + configOptions: configOptions(snapshot, { + model: state.model ?? model, + variant: state.variant, + modeId: state.modeId, + }), + } + } + + if (params.configId === "mode") { + if (!snapshot.availableModes.some((mode) => mode.id === params.value)) { + return yield* new ACPError.InvalidModeError({ mode: params.value }) + } + const state = yield* session.setMode(params.sessionId, params.value) + return { + configOptions: configOptions(snapshot, { + model: state.model ?? selectDefaultModel(snapshot), + variant: state.variant, + modeId: state.modeId, + }), + } + } + + return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId }) + }) + + const setSessionMode = Effect.fn("ACP.setSessionMode")(function* (params: SetSessionModeRequest) { + const current = yield* session.get(params.sessionId) + const snapshot = yield* configSnapshot(current) + if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) { + return yield* new ACPError.InvalidModeError({ mode: params.modeId }) + } + yield* session.setMode(params.sessionId, params.modeId) + return {} + }) + + const setSessionModel = Effect.fn("ACP.setSessionModel")(function* (params: SetSessionModelRequest) { + const current = yield* session.get(params.sessionId) + const snapshot = yield* configSnapshot(current) + const selected = yield* parseSelectedModel(snapshot, params.modelId) + const state = yield* session + .setVariant(params.sessionId, selectModelVariant(snapshot, current, selected)) + .pipe(Effect.andThen(session.setModel(params.sessionId, selected.model))) + yield* sendConfigOptionUpdate( + input.connection, + params.sessionId, + configOptions(snapshot, { + model: state.model ?? selected.model, + variant: state.variant, + modeId: state.modeId, + }), + ) + return {} + }) + + return { + initialize, + authenticate, + newSession, + loadSession, + listSessions, + resumeSession, + closeSession, + forkSession, + setSessionConfigOption, + setSessionMode, + setSessionModel, + prompt: Effect.fn("ACP.prompt")(function* (params: PromptRequest) { + const current = yield* session.get(params.sessionId) + const snapshot = yield* directorySnapshot(current.cwd) + const selected = current.model ?? selectDefaultModel(snapshot) + if (!current.model) { + yield* session.setModel(params.sessionId, selected) + } + const variant = current.variant ?? selectVariant(snapshot, selected) + const modeId = current.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined) + const parts = promptContentToParts(params.prompt) + const command = detectSlashCommand(parts) + + if (!command) { + const response = yield* request( + () => + runUntilIdle(current.id, () => + input.sdk.session.prompt( + { + sessionID: current.id, + model: { + providerID: selected.providerID, + modelID: selected.modelID, + }, + ...(variant ? { variant } : {}), + parts, + ...(modeId ? { agent: modeId } : {}), + directory: current.cwd, + }, + { throwOnError: true }, + ), + ), + "session", + ) + yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) + return yield* promptResponse(response.info, params.messageId) + } + + const known = snapshot.availableCommands.find((item) => item.name === command.name) + if (known) { + const response = yield* request( + () => + runUntilIdle(current.id, () => + input.sdk.session.command( + { + sessionID: current.id, + command: known.name, + arguments: command.args, + model: `${selected.providerID}/${selected.modelID}`, + ...(variant ? { variant } : {}), + ...(modeId ? { agent: modeId } : {}), + directory: current.cwd, + }, + { throwOnError: true }, + ), + ), + "session", + ) + yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) + return yield* promptResponse(response.info, params.messageId) + } + + if (command.name === "compact") { + yield* request( + () => + runUntilIdle(current.id, () => + input.sdk.session.summarize( + { + sessionID: current.id, + directory: current.cwd, + providerID: selected.providerID, + modelID: selected.modelID, + }, + { throwOnError: true }, + ), + ), + "session", + ) + } + + yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) + return yield* promptResponse(undefined, params.messageId) + }), + cancel, + } +} + +function makeSessionService() { + return ManagedRuntime.make(AppNodeBuilder.build(ACPSession.node)).runSync( + ACPSession.Service.use((service) => Effect.succeed(service)), + ) +} + +function makeDirectoryService(sdk: OpencodeClient) { + return ManagedRuntime.make( + AppNodeBuilder.build(Directory.node, [ + [ + Directory.loaderNode, + Layer.succeed( + Directory.Loader, + Directory.Loader.of({ + load: (directory) => request(() => loadDirectorySnapshot(sdk, directory), "directory"), + }), + ), + ], + ]), + ).runSync(Directory.Service.use((service) => Effect.succeed(service))) +} + +function makeUsageService(sdk: OpencodeClient) { + const limits = new Map>() + const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACP.promptUsage.contextLimit")( + function* (params) { + const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}` + const current = limits.get(key) + if (current) return yield* Effect.promise(() => current) + + const next = sdk.config + .providers({ directory: params.directory }, { throwOnError: true }) + .then((response) => { + const providers = Object.fromEntries( + (response.data?.providers ?? []).map((provider) => [provider.id, provider]), + ) as Record + return UsageService.findContextLimit(providers, params.providerID, params.modelID) + }) + .catch(() => undefined) + limits.set(key, next) + return yield* Effect.promise(() => next) + }, + ) + + const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACP.promptUsage.sendUpdate")(function* (params) { + const messages = yield* request( + () => + sdk.session.messages( + { + sessionID: params.sessionID, + directory: params.directory, + }, + { throwOnError: true }, + ), + "session", + ).pipe( + Effect.map((messages) => messages as readonly UsageService.SessionMessage[]), + Effect.catch((error) => + Effect.logError("failed to fetch messages for usage update", { error: error }).pipe(Effect.as(undefined)), + ), + ) + if (!messages) return + + const message = UsageService.latestAssistantMessage(messages) + if (!message?.providerID || !message.modelID) return + + const size = yield* contextLimit({ + directory: params.directory, + providerID: ProviderV2.ID.make(message.providerID), + modelID: ModelV2.ID.make(message.modelID), + }) + if (!size) return + + yield* Effect.promise(() => + params.connection + .sessionUpdate({ + sessionId: params.sessionID, + update: { + sessionUpdate: "usage_update", + used: UsageService.contextTokens(message), + size, + cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" }, + }, + }) + .catch(() => {}), + ) + }) + + return UsageService.Service.of({ + buildUsage: UsageService.buildUsage, + latestAssistantMessage: UsageService.latestAssistantMessage, + totalSessionCost: UsageService.totalSessionCost, + contextLimit, + sendUpdate, + }) +} + +function replayMessages(subscription: ACPEvent.Subscription | undefined, messages: SessionMessageResponse[]) { + if (!subscription) return Effect.void + return Effect.promise(async () => { + for (const message of messages) { + await subscription.replayMessage(message).catch(() => {}) + } + }) +} + +type ConfigState = { + readonly model: Directory.DefaultModel + readonly variant?: string + readonly modeId?: string +} + +type SdkResponse = { + readonly data?: T + readonly error?: unknown +} + +type MessageInfo = { + readonly role?: Message["role"] + readonly model?: Extract["model"] + readonly providerID?: Extract["providerID"] + readonly modelID?: Extract["modelID"] + readonly variant?: Extract["variant"] + readonly mode?: Extract["mode"] + readonly agent?: Message["agent"] +} + +type AssistantError = NonNullable +type AssistantInfo = (UsageService.AssistantTokenCost & Pick) | undefined + +function request(fn: () => Promise>, service?: string) { + return Effect.tryPromise({ + try: async () => { + const result = await fn() + if (isSdkResponse(result)) { + if (result.error) throw result.error + if (result.data !== undefined) return result.data + } + return result as T + }, + catch: (error) => fromUnknownError(error, service), + }) +} + +function profiledRequest(name: string, fn: () => Promise>, service?: string) { + return request(() => ACPProfile.measure(name, fn), service) +} + +async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { + return ACPProfile.measure("acp.directory.load", async () => { + const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([ + ACPProfile.measure("acp.directory.provider.list", () => + sdk.config.providers({ directory }, { throwOnError: true }), + ), + ACPProfile.measure("acp.directory.mode.defaultAgent.load", () => + sdk.app.agents({ directory }, { throwOnError: true }), + ), + ACPProfile.measure("acp.directory.command.list", () => sdk.command.list({ directory }, { throwOnError: true })), + ACPProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })), + ACPProfile.measure("acp.directory.defaultModel.config", () => + sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined), + ), + ]) + const providersData = providersResponse.data! + const agents = agentsResponse.data! + const commandsData = commandsResponse.data! + const skills = skillsResponse.data! + const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record< + ProviderV2.ID, + Provider.Info + > + const defaultModelStarted = performance.now() + const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers) + ACPProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel }) + const modes = agents + .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) + .map((agent) => ({ + id: agent.name, + name: agent.name, + ...(agent.description ? { description: agent.description } : {}), + })) + const commands = [ + ...commandsData, + ...skills + .filter((skill) => !commandsData.some((command) => command.name === skill.name)) + .map((skill) => ({ + name: skill.name, + description: skill.description, + source: "skill" as const, + template: skill.content, + hints: [], + })), + ] as Command.Info[] + + return Directory.build({ + directory, + providers, + modes, + defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build", + commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), + ...(defaultModel ? { defaultModel } : {}), + }) + }) +} + +function defaultModelFromConfig( + configuredModel: string | undefined, + providers: Record, +): Directory.DefaultModel | undefined { + const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined + if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured + + // First-session ACP startup must not scan historical sessions just to infer + // a default. Configured model, opencode provider, then sorted best model keep + // the protocol response deterministic without extra session/message reads. + const opencodeProvider = providers[ProviderV2.ID.make("opencode")] + const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined + if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id } + + const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models)))[0] + if (best) return { providerID: best.providerID, modelID: best.id } + if (configured) return configured +} + +function selectDefaultModel(snapshot: Directory.Snapshot) { + if (snapshot.defaultModel) return snapshot.defaultModel + const model = snapshot.modelOptions[0] + if (model) return { providerID: model.providerID, modelID: model.modelID } + return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ModelV2.ID } +} + +function detectSlashCommand(parts: ReturnType) { + const text = parts + .filter((part): part is Extract<(typeof parts)[number], { type: "text" }> => part.type === "text") + .map((part) => part.text) + .join("") + .trim() + if (!text.startsWith("/")) return + + const [name, ...rest] = text.slice(1).split(/\s+/) + if (!name) return + return { name, args: rest.join(" ").trim() } +} + +const promptResponse = Effect.fn("ACP.promptResponse")(function* ( + info: AssistantInfo, + messageId: string | null | undefined, +) { + if (!info?.error) { + return { + stopReason: "end_turn" as const, + ...(info ? { usage: UsageService.buildUsage(info) } : {}), + ...(messageId ? { userMessageId: messageId } : {}), + _meta: {}, + } + } + + const base = { + usage: UsageService.buildUsage(info), + ...(messageId ? { userMessageId: messageId } : {}), + _meta: {}, + } + + if (info.error.name === "MessageAbortedError") { + return { + stopReason: "cancelled" as const, + ...base, + } + } + + if (info.error.name === "MessageOutputLengthError") { + return { + stopReason: "max_tokens" as const, + ...base, + } + } + + if (info.error.name === "ContentFilterError") { + return { + stopReason: "refusal" as const, + ...base, + } + } + + if (info.error.name === "ProviderAuthError") { + return yield* new ACPError.AuthRequiredError({ providerId: info.error.data.providerID }) + } + + return yield* new ACPError.ServiceFailureError({ + service: "session", + safeMessage: promptErrorMessage(info.error), + errorName: info.error.name, + }) +}) + +function promptErrorMessage(error: AssistantError) { + if ("message" in error.data && typeof error.data.message === "string") return error.data.message + return "OpenCode prompt failed" +} + +function sendUsageUpdate( + usage: UsageService.Interface | undefined, + sdk: OpencodeClient, + connection: ServiceConnection | undefined, + sessionID: string, + directory: string, +) { + if (!connection) return Effect.void + return (usage ?? makeUsageService(sdk)).sendUpdate({ + connection, + sessionID, + directory, + }) +} + +function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) { + const variants = Directory.variants(snapshot, model) + if (!variants) return + if (variants.default) return "default" + return Object.keys(variants)[0] +} + +function selectModelVariant( + snapshot: Directory.Snapshot, + current: ACPSession.Info, + selected: { model: Directory.DefaultModel; variant?: string }, +) { + const variants = Directory.variants(snapshot, selected.model) + if (!variants) return + if (selected.variant) return selected.variant + if (sameModel(selected.model, current.model) && current.variant && hasVariant(variants, current.variant)) + return current.variant + return selectVariant(snapshot, selected.model) +} + +function hasVariant(variants: Directory.ModelVariants, variant: string) { + // "default" is also the persisted sentinel for no explicit variant override. + return variant === DEFAULT_VARIANT_VALUE || Object.hasOwn(variants, variant) +} + +function configOptions(snapshot: Directory.Snapshot, session: ConfigState) { + return buildConfigOptions({ + providers: Object.values(snapshot.providers), + currentModel: session.model, + currentVariant: session.variant, + modes: snapshot.availableModes, + currentModeId: session.modeId, + }) +} + +function sendConfigOptionUpdate( + connection: ServiceConnection | undefined, + sessionId: string, + options: ReturnType, +) { + if (!connection) return Effect.void + return Effect.tryPromise({ + try: () => + connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions: options, + }, + }), + catch: () => undefined, + }).pipe(Effect.ignore) +} + +function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) { + const selected = parseModelSelection(modelId, Object.values(snapshot.providers)) + const provider = snapshot.providers[ProviderV2.ID.make(selected.model.providerID)] + const model = provider?.models[ModelV2.ID.make(selected.model.modelID)] + if (!model) { + return Effect.fail( + new ACPError.InvalidModelError({ + providerId: selected.model.providerID, + modelId, + }), + ) + } + if (selected.variant && !model.variants?.[selected.variant]) { + return Effect.fail(new ACPError.InvalidEffortError({ effort: selected.variant })) + } + return Effect.succeed({ + model: { + providerID: provider.id, + modelID: model.id, + }, + variant: selected.variant, + }) +} + +function sendAvailableCommands( + connection: Pick | undefined, + sessionId: string, + snapshot: Directory.Snapshot, +) { + if (!connection) return Effect.void + return Effect.sync(() => { + setTimeout(() => { + void connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: snapshot.availableCommands.map((command) => ({ + name: command.name, + description: command.description ?? "", + })), + }, + }) + }, 0) + }) +} + +function registerMcpServers( + sdk: OpencodeClient, + registered: Map>, + directory: string, + sessionId: string, + servers: readonly McpServer[], +) { + const started = performance.now() + const current = registered.get(sessionId) ?? new Set() + registered.set(sessionId, current) + const pending = new Set() + + return Effect.all( + servers + .map((server) => ({ server, config: mcpConfig(server) })) + .filter((entry) => { + const key = mcpRegistrationKey(entry.server.name, entry.config) + if (current.has(key) || pending.has(key)) return false + pending.add(key) + return true + }) + .map((entry) => + request( + () => + sdk.mcp.add( + { + directory, + name: entry.server.name, + config: entry.config, + }, + { throwOnError: true }, + ), + "mcp", + ).pipe( + Effect.tap(() => Effect.sync(() => current.add(mcpRegistrationKey(entry.server.name, entry.config)))), + Effect.ignore, + ), + ), + { concurrency: "unbounded" }, + ).pipe( + Effect.tap(() => + Effect.sync(() => + ACPProfile.duration("acp.mcp.register", started, { + count: pending.size, + }), + ), + ), + Effect.asVoid, + ) +} + +function mcpRegistrationKey(name: string, config: ReturnType) { + return `${name}:${stableStringify(config)}` +} + +function mcpConfig(server: McpServer) { + if ("type" in server) { + return { + type: "remote" as const, + url: server.url, + headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])), + } + } + return { + type: "local" as const, + command: [server.command, ...server.args], + environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])), + } +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]` + if (!value || typeof value !== "object") return JSON.stringify(value) + return `{${Object.entries(value) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(",")}}` +} + +function restoreSession( + snapshot: Directory.Snapshot, + backing: Pick, + messages: MessageInfo[], +) { + const history = restoreFromMessages(messages) + const durable = restoreDurableModel(backing.model) + const model = restoreModel(snapshot, durable.model, history.model) + return { + model, + variant: restoreVariant(snapshot, model, durable, history), + modeId: restoreMode(snapshot, backing.agent, history.modeId), + } +} + +function restoreDurableModel(model: Session["model"] | undefined) { + if (!model) return {} + return { + model: { + providerID: ProviderV2.ID.make(model.providerID), + modelID: ModelV2.ID.make(model.id), + }, + variant: model.variant, + } +} + +function restoreModel( + snapshot: Directory.Snapshot, + durable: Directory.DefaultModel | undefined, + history: Directory.DefaultModel | undefined, +) { + if (durable && hasModel(snapshot, durable)) return durable + if (history && hasModel(snapshot, history)) return history + return selectDefaultModel(snapshot) +} + +function restoreVariant( + snapshot: Directory.Snapshot, + model: Directory.DefaultModel, + durable: { model?: Directory.DefaultModel; variant?: string }, + history: { model?: Directory.DefaultModel; variant?: string }, +) { + const variants = Directory.variants(snapshot, model) + if (!variants) return + if (sameModel(model, durable.model) && durable.variant && hasVariant(variants, durable.variant)) + return durable.variant + if (sameModel(model, history.model) && history.variant && hasVariant(variants, history.variant)) + return history.variant + return selectVariant(snapshot, model) +} + +function restoreMode(snapshot: Directory.Snapshot, durable: string | undefined, history: string | undefined) { + if (hasMode(snapshot, durable)) return durable + if (hasMode(snapshot, history)) return history + if (snapshot.availableModes.length > 0) return snapshot.defaultModeID +} + +function hasModel(snapshot: Directory.Snapshot, model: Directory.DefaultModel) { + return Boolean(snapshot.providers[model.providerID]?.models[model.modelID]) +} + +function hasMode(snapshot: Directory.Snapshot, modeId: string | undefined) { + return Boolean(modeId && snapshot.availableModes.some((mode) => mode.id === modeId)) +} + +function sameModel(left: Directory.DefaultModel, right: Directory.DefaultModel | undefined) { + return left.providerID === right?.providerID && left.modelID === right.modelID +} + +function restoreFromMessages(messages: readonly MessageInfo[]) { + const user = messages.findLast( + (message) => message.role === "user" && message.model?.providerID && message.model.modelID, + ) + if (user?.model?.providerID && user.model.modelID) { + return { + model: { providerID: user.model.providerID as ProviderV2.ID, modelID: user.model.modelID as ModelV2.ID }, + variant: user.model.variant, + modeId: user.agent, + } + } + + const assistant = messages.findLast((message) => message.providerID && message.modelID) + if (assistant?.providerID && assistant.modelID) { + return { + model: { providerID: assistant.providerID as ProviderV2.ID, modelID: assistant.modelID as ModelV2.ID }, + variant: assistant.variant, + modeId: assistant.mode ?? assistant.agent, + } + } + + return {} +} + +function isSdkResponse(value: T | SdkResponse): value is SdkResponse { + return typeof value === "object" && value !== null && ("data" in value || "error" in value) +} + +function fromUnknownError(error: unknown, service?: string): Error { + if (isACPError(error)) return error + if (isAuthRequired(error)) { + return new ACPError.AuthRequiredError({ providerId: findProviderID(error) }) + } + return new ACPError.ServiceFailureError({ safeMessage: "OpenCode service failure", service }) +} + +function isACPError(error: unknown): error is Error { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + typeof error._tag === "string" && + error._tag.startsWith("ACP") + ) +} + +function isAuthRequired(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false + if (value instanceof Error && (value.name === "ProviderAuthError" || value.name === "LoadAPIKeyError")) return true + if ( + value instanceof Error && + (value.message.includes("ProviderAuthError") || value.message.includes("LoadAPIKeyError")) + ) { + return true + } + if ("name" in value && (value.name === "ProviderAuthError" || value.name === "LoadAPIKeyError")) return true + if ("_tag" in value && (value._tag === "ProviderAuthError" || value._tag === "LoadAPIKeyError")) return true + if ("error" in value && isAuthRequired(value.error)) return true + if ("data" in value && isAuthRequired(value.data)) return true + return false +} + +function findProviderID(value: unknown): string | undefined { + if (typeof value !== "object" || value === null) return + if ("providerID" in value && typeof value.providerID === "string") return value.providerID + if ("providerId" in value && typeof value.providerId === "string") return value.providerId + if ("data" in value) return findProviderID(value.data) + if ("error" in value) return findProviderID(value.error) +} diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6a7a56bac2f40e4d4cce8246b783608c2b82617 --- /dev/null +++ b/packages/opencode/src/acp/session.ts @@ -0,0 +1,232 @@ +import type { McpServer } from "@agentclientprotocol/sdk" +import type { Message, Part } from "@opencode-ai/sdk/v2" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { Context, Effect, Layer, Ref } from "effect" +import * as ACPError from "./error" + +export type SelectedModel = { + providerID: ProviderV2.ID + modelID: ModelV2.ID +} + +export type KnownMessagePartMetadata = { + messageId: string + partId: string + partType?: Part["type"] + role?: Message["role"] + ignored?: boolean + toolCallId?: string + metadata?: unknown +} + +export type Info = { + id: string + cwd: string + mcpServers: readonly McpServer[] + createdAt: Date + model?: SelectedModel + variant?: string + modeId?: string + knownParts: ReadonlyMap +} + +export type StoreInput = { + id: string + cwd: string + mcpServers?: readonly McpServer[] + createdAt?: Date + model?: SelectedModel + variant?: string + modeId?: string +} + +export type RecordPartMetadataInput = { + sessionId: string + messageId: string + partId: string + partType?: Part["type"] + role?: Message["role"] + ignored?: boolean + toolCallId?: string + metadata?: unknown +} + +export type PartMetadataLookupInput = { + sessionId: string + messageId: string + partId: string +} + +export type Interface = { + readonly create: (input: StoreInput) => Effect.Effect + readonly load: (input: StoreInput) => Effect.Effect + readonly list: (cwd?: string) => Effect.Effect + readonly get: (sessionId: string) => Effect.Effect + readonly tryGet: (sessionId: string) => Effect.Effect + readonly remove: (sessionId: string) => Effect.Effect + readonly setModel: ( + sessionId: string, + model: SelectedModel | undefined, + ) => Effect.Effect + readonly getModel: (sessionId: string) => Effect.Effect + readonly setVariant: ( + sessionId: string, + variant: string | undefined, + ) => Effect.Effect + readonly getVariant: (sessionId: string) => Effect.Effect + readonly setMode: ( + sessionId: string, + modeId: string | undefined, + ) => Effect.Effect + readonly getMode: (sessionId: string) => Effect.Effect + readonly recordPartMetadata: ( + input: RecordPartMetadataInput, + ) => Effect.Effect + readonly getPartMetadata: ( + input: PartMetadataLookupInput, + ) => Effect.Effect + readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ACP/Session") {} + +type State = Map + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const sessions = yield* Ref.make(new Map()) + + const store = Effect.fn("ACP.Session.store")(function* (input: StoreInput) { + const session = makeSession(input) + yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session)) + return snapshot(session) + }) + + const tryGet = Effect.fn("ACP.Session.tryGet")(function* (sessionId: string) { + const session = (yield* Ref.get(sessions)).get(sessionId) + if (!session) return + return snapshot(session) + }) + + const get = Effect.fn("ACP.Session.get")(function* (sessionId: string) { + const session = yield* tryGet(sessionId) + if (session) return session + return yield* new ACPError.SessionNotFoundError({ sessionId }) + }) + + const update = Effect.fn("ACP.Session.update")(function* (sessionId: string, fn: (session: Info) => Info) { + const result = yield* Ref.modify(sessions, (state) => { + const session = state.get(sessionId) + if (!session) return [undefined, state] as const + const next = fn(session) + return [snapshot(next), new Map(state).set(sessionId, next)] as const + }) + if (result) return result + return yield* new ACPError.SessionNotFoundError({ sessionId }) + }) + + const remove = Effect.fn("ACP.Session.remove")(function* (sessionId: string) { + return yield* Ref.modify(sessions, (state) => { + const session = state.get(sessionId) + if (!session) return [undefined, state] as const + const next = new Map(state) + next.delete(sessionId) + return [snapshot(session), next] as const + }) + }) + + const setModel: Interface["setModel"] = Effect.fn("ACP.Session.setModel")((sessionId, model) => + update(sessionId, (session) => ({ ...session, model })), + ) + + const setVariant: Interface["setVariant"] = Effect.fn("ACP.Session.setVariant")((sessionId, variant) => + update(sessionId, (session) => ({ ...session, variant })), + ) + + const setMode: Interface["setMode"] = Effect.fn("ACP.Session.setMode")((sessionId, modeId) => + update(sessionId, (session) => ({ ...session, modeId })), + ) + + const recordPartMetadata: Interface["recordPartMetadata"] = Effect.fn("ACP.Session.recordPartMetadata")((input) => { + const metadata = { + messageId: input.messageId, + partId: input.partId, + partType: input.partType, + role: input.role, + ignored: input.ignored, + toolCallId: input.toolCallId, + metadata: input.metadata, + } + return update(input.sessionId, (session) => ({ + ...session, + knownParts: new Map(session.knownParts).set(partMetadataKey(input), metadata), + })).pipe(Effect.as(metadata)) + }) + + return Service.of({ + create: store, + load: store, + list: Effect.fn("ACP.Session.list")(function* (cwd?: string) { + return [...(yield* Ref.get(sessions)).values()] + .filter((session) => !cwd || session.cwd === cwd) + .map(snapshot) + .toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + }), + get, + tryGet, + remove, + setModel, + getModel: Effect.fn("ACP.Session.getModel")(function* (sessionId) { + return (yield* get(sessionId)).model + }), + setVariant, + getVariant: Effect.fn("ACP.Session.getVariant")(function* (sessionId) { + return (yield* get(sessionId)).variant + }), + setMode, + getMode: Effect.fn("ACP.Session.getMode")(function* (sessionId) { + return (yield* get(sessionId)).modeId + }), + recordPartMetadata, + getPartMetadata: Effect.fn("ACP.Session.getPartMetadata")(function* (input) { + return (yield* get(input.sessionId)).knownParts.get(partMetadataKey(input)) + }), + tryGetPartMetadata: Effect.fn("ACP.Session.tryGetPartMetadata")(function* (input) { + return (yield* tryGet(input.sessionId))?.knownParts.get(partMetadataKey(input)) + }), + }) + }), +) + +export const node = LayerNode.make({ service: Service, layer, deps: [] }) + +function makeSession(input: StoreInput): Info { + return { + id: input.id, + cwd: input.cwd, + mcpServers: [...(input.mcpServers ?? [])], + createdAt: input.createdAt ? new Date(input.createdAt) : new Date(), + model: input.model, + variant: input.variant, + modeId: input.modeId, + knownParts: new Map(), + } +} + +function snapshot(session: Info): Info { + return { + ...session, + mcpServers: [...session.mcpServers], + createdAt: new Date(session.createdAt), + knownParts: new Map(session.knownParts), + } +} + +function partMetadataKey(input: { messageId: string; partId: string }) { + return `${input.messageId}:${input.partId}` +} + +export * as ACPSession from "./session" diff --git a/packages/opencode/src/acp/tool.ts b/packages/opencode/src/acp/tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..309c126d6e9958d34a93c6e773e1897ae2b3434b --- /dev/null +++ b/packages/opencode/src/acp/tool.ts @@ -0,0 +1,364 @@ +import { isAbsolute, resolve } from "path" +import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk" + +export type ToolInput = Record + +export type ToolAttachment = { + readonly mime?: string + readonly url?: string + readonly [key: string]: unknown +} + +export type CompletedToolState = { + readonly status: "completed" + readonly input: ToolInput + readonly output: string + readonly metadata?: unknown + readonly attachments?: ReadonlyArray +} + +export type RunningToolState = { + readonly status: "running" + readonly input: ToolInput + readonly title?: string +} + +export type ErrorToolState = { + readonly status: "error" + readonly input: ToolInput + readonly error: string + readonly metadata?: unknown +} + +export type ImageAttachment = { + readonly mimeType: string + readonly data: string +} + +export function toToolKind(toolName: string): ToolKind { + const tool = toolName.toLocaleLowerCase() + + switch (tool) { + case "bash": + case "shell": + return "execute" + + case "webfetch": + return "fetch" + + case "edit": + case "apply_patch": + case "patch": + case "write": + return "edit" + + case "grep": + case "glob": + case "context": + case "context7_resolve_library_id": + case "context7_get_library_docs": + return "search" + + case "read": + return "read" + + case "task": + return "think" + + default: + return "other" + } +} + +export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] { + const tool = toolName.toLocaleLowerCase() + + switch (tool) { + case "bash": + case "shell": { + const workdir = shellWorkdir(input, cwd) + return workdir ? [{ path: workdir }] : [] + } + + case "read": + case "edit": + case "write": + return locationFrom(input.filePath ?? input.filepath) + + case "external_directory": + return locationFrom(input.filePath ?? input.filepath, input.parentDir, input.directories) + + case "grep": + case "glob": + case "context": + case "context7_resolve_library_id": + case "context7_get_library_docs": + return locationFrom(input.path) + + default: + return [] + } +} + +export function completedToolContent(toolName: string, state: CompletedToolState): ToolCallContent[] { + const text = + toolName.toLocaleLowerCase() === "read" ? (readDisplayText(state.metadata) ?? state.output) : state.output + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text, + }, + }, + ] + + if (toToolKind(toolName) === "edit") { + content.push(...diffContent(state.input)) + } + + content.push(...imageContents(state.attachments ?? [])) + return content +} + +export function pendingToolCall(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: { readonly input: ToolInput; readonly title?: string } + readonly cwd?: string +}): ToolCall { + return { + toolCallId: input.toolCallId, + title: toolTitle(input.toolName, input.state.input, input.state.title), + kind: toToolKind(input.toolName), + status: "pending", + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), + } +} + +export function runningToolUpdate(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: RunningToolState + readonly output?: string + readonly cwd?: string +}): ToolCallUpdate { + const content = input.output + ? [ + { + type: "content" as const, + content: { + type: "text" as const, + text: input.output, + }, + }, + ] + : undefined + + return { + toolCallId: input.toolCallId, + status: "in_progress", + kind: toToolKind(input.toolName), + title: toolTitle(input.toolName, input.state.input, input.state.title), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), + ...(content ? { content } : {}), + } +} + +export function duplicateRunningToolUpdate(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: RunningToolState + readonly cwd?: string +}): ToolCallUpdate { + return { + toolCallId: input.toolCallId, + status: "in_progress", + kind: toToolKind(input.toolName), + title: toolTitle(input.toolName, input.state.input, input.state.title), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), + } +} + +export function completedToolUpdate(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: CompletedToolState & { readonly title?: string } + readonly cwd?: string +}): ToolCallUpdate { + return { + toolCallId: input.toolCallId, + status: "completed", + ...(input.state.title ? { title: input.state.title } : {}), + content: completedToolContent(input.toolName, input.state), + rawOutput: completedToolRawOutput(input.state), + } +} + +export function errorToolUpdate(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: ErrorToolState + readonly cwd?: string +}): ToolCallUpdate { + return { + toolCallId: input.toolCallId, + status: "failed", + kind: toToolKind(input.toolName), + title: toolTitle(input.toolName, input.state.input, undefined), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), + content: [ + { + type: "content", + content: { + type: "text", + text: input.state.error, + }, + }, + ], + rawOutput: { + error: input.state.error, + metadata: input.state.metadata, + }, + } +} + +export function completedToolRawOutput(state: CompletedToolState) { + return { + output: state.output, + ...(state.metadata !== undefined ? { metadata: state.metadata } : {}), + ...(state.attachments?.length ? { attachments: state.attachments } : {}), + } +} + +export function imageContents(attachments: ReadonlyArray): ToolCallContent[] { + return extractImageAttachments(attachments).map((attachment): ToolCallContent => { + return { + type: "content", + content: { + type: "image", + mimeType: attachment.mimeType, + data: attachment.data, + }, + } + }) +} + +export function extractImageAttachments(attachments: ReadonlyArray): ImageAttachment[] { + return attachments.flatMap((attachment): ImageAttachment[] => { + const data = dataUrlImage(attachment) + return data ? [data] : [] + }) +} + +export function shellOutputSnapshot(state: { readonly metadata?: unknown }) { + if (!state.metadata || typeof state.metadata !== "object") return undefined + return stringValue((state.metadata as Record).output) +} + +// For shell tools, surface the actual command as the title so it stays visible +// before output lands; non-shell tools keep their model-provided title. +function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) { + if (isShell(toolName)) return shellCommand(input) ?? fallback ?? toolName + return fallback || toolName +} + +// Enrich shell rawInput with the resolved working directory so clients can show +// where the command runs, unless the model already specified one. +function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput { + if (!isShell(toolName)) return input + if (input.cwd || input.workdir) return input + const workdir = shellWorkdir(input, cwd) + return workdir ? { ...input, cwd: workdir } : input +} + +function shellWorkdir(input: ToolInput, cwd?: string) { + const explicit = stringValue(input.workdir) ?? stringValue(input.cwd) + return resolvePath(explicit, cwd) ?? cwd +} + +function resolvePath(value: string | undefined, cwd?: string) { + if (!value) return undefined + if (isAbsolute(value)) return value + return resolve(cwd ?? process.cwd(), value) +} + +function shellCommand(input: ToolInput) { + return stringValue(input.command) ?? stringValue(input.cmd) +} + +function isShell(toolName: string) { + const tool = toolName.toLocaleLowerCase() + return tool === "bash" || tool === "shell" +} + +export const mapToolKind = toToolKind +export const extractLocations = toLocations +export const buildCompletedToolContent = completedToolContent +export const buildCompletedRawOutput = completedToolRawOutput +export const extractShellOutputSnapshot = shellOutputSnapshot +export const buildPendingToolCall = pendingToolCall +export const buildRunningToolUpdate = runningToolUpdate +export const buildDuplicateRunningToolUpdate = duplicateRunningToolUpdate +export const buildCompletedToolUpdate = completedToolUpdate +export const buildErrorToolUpdate = errorToolUpdate + +function locationFrom(...values: unknown[]): ToolCallLocation[] { + return Array.from( + new Set( + values.flatMap((value): string[] => { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === "string" && item.length > 0) + } + const path = stringValue(value) + return path ? [path] : [] + }), + ), + (path) => ({ path }), + ) +} + +function diffContent(input: ToolInput): ToolCallContent[] { + const oldText = stringValue(input.oldString) + const newText = stringValue(input.newString) ?? stringValue(input.content) + if (oldText === undefined || newText === undefined) return [] + + return [ + { + type: "diff", + path: stringValue(input.filePath) ?? "", + oldText, + newText, + }, + ] +} + +function readDisplayText(metadata: unknown) { + if (!metadata || typeof metadata !== "object") return undefined + const display = (metadata as Record).display + if (!display || typeof display !== "object") return undefined + const info = display as Record + if (info.type === "file") return stringValue(info.text) + if (info.type === "directory" && Array.isArray(info.entries)) { + return info.entries.filter((item): item is string => typeof item === "string").join("\n") + } + return undefined +} + +function dataUrlImage(attachment: ToolAttachment) { + const match = stringValue(attachment.url)?.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) + const mime = match?.[1] ?? stringValue(attachment.mime) + if (!mime?.startsWith("image/")) return undefined + + const data = match?.[2] + if (data === undefined) return undefined + return { mimeType: mime, data } +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : undefined +} diff --git a/packages/opencode/src/acp/usage.ts b/packages/opencode/src/acp/usage.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc17447e6141efaee5bffbec20a32521893e9f14 --- /dev/null +++ b/packages/opencode/src/acp/usage.ts @@ -0,0 +1,243 @@ +import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk" +import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" +import { makeGlobalNode, Node } from "@opencode-ai/core/effect/app-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { Provider } from "@/provider/provider" +import { Context, Effect, Layer, SynchronizedRef } from "effect" + +export type AssistantTokenCost = Pick + +export type AssistantMessage = AssistantTokenCost & + Pick & + Partial> + +export type SessionMessage = { + readonly info: { readonly role: Message["role"] } | AssistantMessage +} + +export type MessagesInput = { + readonly sessionID: string + readonly directory: string +} + +export type SDK = { + readonly session: { + readonly messages: ( + parameters: { readonly sessionID: string; readonly directory: string }, + options: { readonly throwOnError: true }, + ) => Promise<{ readonly data?: readonly SessionMessage[] | null }> + } +} + +export interface MessageLoaderInterface { + readonly messages: (input: MessagesInput) => Effect.Effect +} + +export interface ContextLimitLoaderInterface { + readonly providers: (directory: string) => Effect.Effect, unknown> +} + +export type UsageConnection = Pick + +export interface Interface { + readonly buildUsage: (message: AssistantTokenCost) => Usage + readonly latestAssistantMessage: (messages: readonly SessionMessage[]) => AssistantMessage | undefined + readonly totalSessionCost: (messages: readonly SessionMessage[]) => number + readonly contextLimit: (input: { + readonly directory: string + readonly providerID: ProviderV2.ID + readonly modelID: ModelV2.ID + }) => Effect.Effect + readonly sendUpdate: (input: { + readonly connection: UsageConnection + readonly sessionID: string + readonly directory: string + }) => Effect.Effect +} + +export class MessageLoader extends Context.Service()( + "@opencode/ACPUsageMessageLoader", +) {} + +export class ContextLimitLoader extends Context.Service()( + "@opencode/ACPUsageContextLimitLoader", +) {} + +export class Service extends Context.Service()("@opencode/ACPUsage") {} + +export function messageLoaderFromSDK(sdk: SDK): MessageLoaderInterface { + return MessageLoader.of({ + messages: (input) => + Effect.promise(() => + sdk.session + .messages({ sessionID: input.sessionID, directory: input.directory }, { throwOnError: true }) + .then((response) => response.data ?? []), + ), + }) +} + +export const messageLoaderLayer = (sdk: SDK) => Layer.succeed(MessageLoader, messageLoaderFromSDK(sdk)) + +export function contextTokens(message: AssistantTokenCost): number { + return message.tokens.input + message.tokens.cache.read + message.tokens.cache.write +} + +export function buildUsage(message: AssistantTokenCost): Usage { + const cachedReadTokens = message.tokens.cache.read + const cachedWriteTokens = message.tokens.cache.write + const thoughtTokens = message.tokens.reasoning + + return { + inputTokens: message.tokens.input, + outputTokens: message.tokens.output, + totalTokens: message.tokens.input + message.tokens.output + thoughtTokens + cachedReadTokens + cachedWriteTokens, + ...(thoughtTokens > 0 ? { thoughtTokens } : {}), + ...(cachedReadTokens > 0 ? { cachedReadTokens } : {}), + ...(cachedWriteTokens > 0 ? { cachedWriteTokens } : {}), + } +} + +export function latestAssistantMessage(messages: readonly SessionMessage[]): AssistantMessage | undefined { + return messages + .filter((message): message is { readonly info: AssistantMessage } => message.info.role === "assistant") + .at(-1)?.info +} + +export function totalSessionCost(messages: readonly SessionMessage[]): number { + return messages + .filter((message): message is { readonly info: AssistantMessage } => message.info.role === "assistant") + .reduce((sum, message) => sum + message.info.cost, 0) +} + +export function findContextLimit( + providers: Record, + providerID: ProviderV2.ID, + modelID: ModelV2.ID, +): number | undefined { + return providers[providerID]?.models[modelID]?.limit.context +} + +export const contextLimitLoaderLayer = Layer.effect( + ContextLimitLoader, + Effect.gen(function* () { + const store = yield* InstanceStore.Service + const provider = yield* Provider.Service + + return ContextLimitLoader.of({ + providers: Effect.fn("ACPUsageContextLimitLoader.providers")(function* (directory) { + const ctx = yield* store.load({ directory }) + return yield* Effect.gen(function* () { + return yield* provider.list() + }).pipe(Effect.provideService(InstanceRef, ctx)) + }), + }) + }), +) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const messageLoader = yield* MessageLoader + const contextLimitLoader = yield* ContextLimitLoader + const limits = yield* SynchronizedRef.make(new Map>()) + + const cachedLimit = Effect.fnUntraced(function* (input: { + readonly directory: string + readonly providerID: ProviderV2.ID + readonly modelID: ModelV2.ID + }) { + return yield* SynchronizedRef.modifyEffect( + limits, + Effect.fnUntraced(function* (items) { + const key = `${input.directory}\u0000${input.providerID}\u0000${input.modelID}` + const current = items.get(key) + if (current) return [current, items] as const + const next = yield* Effect.cached( + contextLimitLoader.providers(input.directory).pipe( + Effect.map((providers) => findContextLimit(providers, input.providerID, input.modelID)), + Effect.catch((error) => + Effect.logError("failed to get providers for usage context limit", { error: error }).pipe( + Effect.as(undefined), + ), + ), + ), + ) + return [next, new Map(items).set(key, next)] as const + }), + ) + }) + + const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: { + readonly directory: string + readonly providerID: ProviderV2.ID + readonly modelID: ModelV2.ID + }) { + return yield* yield* cachedLimit(input) + }) + + const sendUpdate = Effect.fn("ACPUsage.sendUpdate")(function* (input: { + readonly connection: UsageConnection + readonly sessionID: string + readonly directory: string + }) { + const messages = yield* messageLoader + .messages({ sessionID: input.sessionID, directory: input.directory }) + .pipe( + Effect.catch((error) => + Effect.logError("failed to fetch messages for usage update", { error: error }).pipe(Effect.as(undefined)), + ), + ) + if (!messages) return + + const message = latestAssistantMessage(messages) + if (!message) return + if (!message.providerID || !message.modelID) return + + const size = yield* contextLimit({ + directory: input.directory, + providerID: ProviderV2.ID.make(message.providerID), + modelID: ModelV2.ID.make(message.modelID), + }) + if (!size) return + + yield* Effect.promise(() => + input.connection + .sessionUpdate({ + sessionId: input.sessionID, + update: { + sessionUpdate: "usage_update", + used: contextTokens(message), + size, + cost: { amount: totalSessionCost(messages), currency: "USD" }, + }, + }) + .catch(() => {}), + ) + }) + + return Service.of({ + buildUsage, + latestAssistantMessage, + totalSessionCost, + contextLimit, + sendUpdate, + }) + }), +) + +export const messageLoaderNode = LayerNode.unbound(MessageLoader, Node.tags.values.global) + +export const contextLimitLoaderNode = makeGlobalNode({ + service: ContextLimitLoader, + layer: contextLimitLoaderLayer, + deps: [Provider.node, InstanceStore.node], +}) + +export const node = makeGlobalNode({ service: Service, layer, deps: [messageLoaderNode, contextLimitLoaderNode] }) + +export * as UsageService from "./usage" diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..536a642fe49fb5211e66c2e2ad689856a03254c0 --- /dev/null +++ b/packages/opencode/src/agent/agent.ts @@ -0,0 +1,453 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Config } from "@/config/config" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { Provider } from "@/provider/provider" + +import { generateObject, streamObject, type ModelMessage } from "ai" +import { Truncate } from "@/tool/truncate" +import { Auth } from "../auth" +import { ProviderTransform } from "@/provider/transform" + +import PROMPT_GENERATE from "./generate.txt" +import PROMPT_COMPACTION from "./prompt/compaction.txt" +import PROMPT_EXPLORE from "./prompt/explore.txt" +import PROMPT_SUMMARY from "./prompt/summary.txt" +import PROMPT_TITLE from "./prompt/title.txt" +import { Permission } from "@/permission" +import { mergeDeep, pipe, sortBy, values } from "remeda" +import { Global } from "@opencode-ai/core/global" +import path from "path" +import { Plugin } from "@/plugin" +import { Skill } from "../skill" +import { Effect, Context, Layer, Schema } from "effect" +import { InstanceState } from "@/effect/instance-state" +import * as Option from "effect/Option" +import * as OtelTracer from "@effect/opentelemetry/Tracer" +import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { Reference } from "@opencode-ai/core/reference" +import { Location } from "@opencode-ai/core/location" +import { PluginV2 } from "@opencode-ai/core/plugin" + +export const Info = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + mode: Schema.Literals(["subagent", "primary", "all"]), + native: Schema.optional(Schema.Boolean), + hidden: Schema.optional(Schema.Boolean), + topP: Schema.optional(Schema.Finite), + temperature: Schema.optional(Schema.Finite), + color: Schema.optional(Schema.String), + permission: PermissionV1.Ruleset, + model: Schema.optional( + Schema.Struct({ + modelID: ModelV2.ID, + providerID: ProviderV2.ID, + }), + ), + variant: Schema.optional(Schema.String), + prompt: Schema.optional(Schema.String), + options: Schema.Record(Schema.String, Schema.Unknown), + steps: Schema.optional(Schema.Finite), +}).annotate({ identifier: "Agent" }) +export type Info = DeepMutable> + +const GeneratedAgent = Schema.Struct({ + identifier: Schema.String, + whenToUse: Schema.String, + systemPrompt: Schema.String, +}) + +export interface Interface { + readonly get: (agent: string) => Effect.Effect + readonly list: () => Effect.Effect + readonly defaultInfo: () => Effect.Effect + readonly defaultAgent: () => Effect.Effect + readonly generate: (input: { + description: string + model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID } + }) => Effect.Effect< + { + identifier: string + whenToUse: string + systemPrompt: string + }, + Provider.DefaultModelError + > +} + +type State = Omit + +export class Service extends Context.Service()("@opencode/Agent") {} + +export const use = serviceUse(Service) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const auth = yield* Auth.Service + const plugin = yield* Plugin.Service + const skill = yield* Skill.Service + const provider = yield* Provider.Service + const locations = yield* LocationServiceMap.Service + + const state = yield* InstanceState.make( + Effect.fn("Agent.state")(function* (ctx) { + const cfg = yield* config.get() + const skillDirs = yield* skill.dirs() + const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length + ? yield* Effect.gen(function* () { + yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) + return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) + }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) + : [] + const whitelistedDirs = [ + Truncate.GLOB, + path.join(Global.Path.tmp, "*"), + ...skillDirs.map((dir) => path.join(dir, "*")), + ...referenceDirs.map((dir) => path.join(dir, "*")), + ] + const readonlyExternalDirectory = { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + } satisfies Record + + const defaults = Permission.fromConfig({ + "*": "allow", + doom_loop: "ask", + external_directory: { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + }, + question: "deny", + plan_enter: "deny", + plan_exit: "deny", + // mirrors github.com/github/gitignore Node.gitignore pattern for .env files + read: { + "*": "allow", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + }) + + const user = Permission.fromConfig(cfg.permission ?? {}) + + const agents: Record = { + build: { + name: "build", + description: "The default agent. Executes tools based on configured permissions.", + options: {}, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + question: "allow", + plan_enter: "allow", + }), + user, + ), + mode: "primary", + native: true, + }, + plan: { + name: "plan", + description: "Plan mode. Disallows all edit tools.", + options: {}, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + question: "allow", + plan_exit: "allow", + task: { + general: "deny", + }, + external_directory: { + [path.join(Global.Path.data, "plans", "*")]: "allow", + }, + edit: { + "*": "deny", + [path.join(".opencode", "plans", "*.md")]: "allow", + [path.relative(ctx.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", + }, + }), + user, + ), + mode: "primary", + native: true, + }, + general: { + name: "general", + description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + todowrite: "deny", + }), + user, + ), + options: {}, + mode: "subagent", + native: true, + }, + explore: { + name: "explore", + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + grep: "allow", + glob: "allow", + list: "allow", + bash: "allow", + webfetch: "allow", + websearch: "allow", + read: "allow", + external_directory: readonlyExternalDirectory, + }), + user, + ), + description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, + prompt: PROMPT_EXPLORE, + options: {}, + mode: "subagent", + native: true, + }, + compaction: { + name: "compaction", + mode: "primary", + native: true, + hidden: true, + prompt: PROMPT_COMPACTION, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + options: {}, + }, + title: { + name: "title", + mode: "primary", + options: {}, + native: true, + hidden: true, + temperature: 0.5, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + prompt: PROMPT_TITLE, + }, + summary: { + name: "summary", + mode: "primary", + options: {}, + native: true, + hidden: true, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + prompt: PROMPT_SUMMARY, + }, + } + + for (const [key, value] of Object.entries(cfg.agent ?? {})) { + if (value.disable) { + delete agents[key] + continue + } + let item = agents[key] + if (!item) + item = agents[key] = { + name: key, + mode: "all", + permission: Permission.merge(defaults, user), + options: {}, + native: false, + } + if (value.model) item.model = Provider.parseModel(value.model) + item.variant = value.variant ?? item.variant + item.prompt = value.prompt ?? item.prompt + item.description = value.description ?? item.description + item.temperature = value.temperature ?? item.temperature + item.topP = value.top_p ?? item.topP + item.mode = value.mode ?? item.mode + item.color = value.color ?? item.color + item.hidden = value.hidden ?? item.hidden + item.name = value.name ?? item.name + item.steps = value.steps ?? item.steps + item.options = mergeDeep(item.options, value.options ?? {}) + item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) + } + + // Ensure Truncate.GLOB is allowed unless explicitly configured + for (const name in agents) { + const agent = agents[name] + const explicit = agent.permission.some((r) => { + if (r.permission !== "external_directory") return false + if (r.action !== "deny") return false + return r.pattern === Truncate.GLOB + }) + if (explicit) continue + + agents[name].permission = Permission.merge( + agents[name].permission, + Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), + ) + } + + const get = Effect.fnUntraced(function* (agent: string) { + return agents[agent] + }) + + const list = Effect.fnUntraced(function* () { + const cfg = yield* config.get() + return pipe( + agents, + values(), + sortBy( + [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"], + [(x) => x.name, "asc"], + ), + ) + }) + + const defaultInfo = Effect.fnUntraced(function* () { + const c = yield* config.get() + if (c.default_agent) { + const agent = agents[c.default_agent] + if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) + if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`) + if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`) + return agent + } + const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) + if (!visible) throw new Error("no primary visible agent found") + return visible + }) + + const defaultAgent = Effect.fnUntraced(function* () { + return (yield* defaultInfo()).name + }) + + return { + get, + list, + defaultInfo, + defaultAgent, + } satisfies State + }), + ) + + return Service.of({ + get: Effect.fn("Agent.get")(function* (agent: string) { + return yield* InstanceState.useEffect(state, (s) => s.get(agent)) + }), + list: Effect.fn("Agent.list")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.list()) + }), + defaultInfo: Effect.fn("Agent.defaultInfo")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.defaultInfo()) + }), + defaultAgent: Effect.fn("Agent.defaultAgent")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.defaultAgent()) + }), + generate: Effect.fn("Agent.generate")(function* (input: { + description: string + model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID } + }) { + const cfg = yield* config.get() + const model = input.model ?? (yield* provider.defaultModel()) + const resolved = yield* provider.getModel(model.providerID, model.modelID) + const language = yield* provider.getLanguage(resolved) + const tracer = cfg.experimental?.openTelemetry + ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer)) + : undefined + + const system = [PROMPT_GENERATE] + yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }) + const existing = yield* InstanceState.useEffect(state, (s) => s.list()) + + // TODO: clean this up so provider specific logic doesnt bleed over + const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie) + const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth" + + const params = { + experimental_telemetry: { + isEnabled: cfg.experimental?.openTelemetry, + tracer, + metadata: { + userId: cfg.username ?? "unknown", + }, + }, + temperature: 0.3, + messages: [ + ...(isOpenaiOauth + ? [] + : system.map( + (item): ModelMessage => ({ + role: "system", + content: item, + }), + )), + { + role: "user", + content: `Create an agent configuration based on this request: "${input.description}".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, + }, + ], + model: language, + schema: Object.assign( + Schema.toStandardSchemaV1(GeneratedAgent), + Schema.toStandardJSONSchemaV1(GeneratedAgent), + ), + } satisfies Parameters[0] + + if (isOpenaiOauth) { + return yield* Effect.promise(async () => { + const result = streamObject({ + ...params, + providerOptions: ProviderTransform.providerOptions(resolved, { + instructions: system.join("\n"), + store: false, + }), + onError: () => {}, + }) + for await (const part of result.fullStream) { + if (part.type === "error") throw part.error + } + return result.object + }) + } + + return yield* Effect.promise(() => generateObject(params).then((r) => r.object)) + }), + }) + }), +) + +const locationServiceMapNode = LayerNode.make({ + service: LocationServiceMap.Service, + layer: locationServiceMapLayer, + deps: [], +}) + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, locationServiceMapNode], +}) + +export * as Agent from "./agent" diff --git a/packages/opencode/src/agent/generate.txt b/packages/opencode/src/agent/generate.txt new file mode 100644 index 0000000000000000000000000000000000000000..774277b0fa8fdba47ae24c3396cf4d8e455acadb --- /dev/null +++ b/packages/opencode/src/agent/generate.txt @@ -0,0 +1,75 @@ +You are an elite AI agent architect specializing in crafting high-performance agent configurations. Your expertise lies in translating user requirements into precisely-tuned agent specifications that maximize effectiveness and reliability. + +**Important Context**: You may have access to project-specific instructions from CLAUDE.md files and other context that may include coding standards, project structure, and custom requirements. Consider this context when creating agents to ensure they align with the project's established patterns and practices. + +When a user describes what they want an agent to do, you will: + +1. **Extract Core Intent**: Identify the fundamental purpose, key responsibilities, and success criteria for the agent. Look for both explicit requirements and implicit needs. Consider any project-specific context from CLAUDE.md files. For agents that are meant to review code, you should assume that the user is asking to review recently written code and not the whole codebase, unless the user has explicitly instructed you otherwise. + +2. **Design Expert Persona**: Create a compelling expert identity that embodies deep domain knowledge relevant to the task. The persona should inspire confidence and guide the agent's decision-making approach. + +3. **Architect Comprehensive Instructions**: Develop a system prompt that: + + - Establishes clear behavioral boundaries and operational parameters + - Provides specific methodologies and best practices for task execution + - Anticipates edge cases and provides guidance for handling them + - Incorporates any specific requirements or preferences mentioned by the user + - Defines output format expectations when relevant + - Aligns with project-specific coding standards and patterns from CLAUDE.md + +4. **Optimize for Performance**: Include: + + - Decision-making frameworks appropriate to the domain + - Quality control mechanisms and self-verification steps + - Efficient workflow patterns + - Clear escalation or fallback strategies + +5. **Create Identifier**: Design a concise, descriptive identifier that: + - Uses lowercase letters, numbers, and hyphens only + - Is typically 2-4 words joined by hyphens + - Clearly indicates the agent's primary function + - Is memorable and easy to type + - Avoids generic terms like "helper" or "assistant" + +6 **Example agent descriptions**: + +- in the 'whenToUse' field of the JSON object, you should include examples of when this agent should be used. +- examples should be of the form: + - + Context: The user is creating a code-review agent that should be called after a logical chunk of code is written. + user: "Please write a function that checks if a number is prime" + assistant: "Here is the relevant function: " + + + Since the user is greeting, use the Task tool to launch the greeting-responder agent to respond with a friendly joke. + + assistant: "Now let me use the code-reviewer agent to review the code" + + - + Context: User is creating an agent to respond to the word "hello" with a friendly jok. + user: "Hello" + assistant: "I'm going to use the Task tool to launch the greeting-responder agent to respond with a friendly joke" + + Since the user is greeting, use the greeting-responder agent to respond with a friendly joke. + + +- If the user mentioned or implied that the agent should be used proactively, you should include examples of this. +- NOTE: Ensure that in the examples, you are making the assistant use the Agent tool and not simply respond directly to the task. + +Your output must be a valid JSON object with exactly these fields: +{ +"identifier": "A unique, descriptive identifier using lowercase letters, numbers, and hyphens (e.g., 'code-reviewer', 'api-docs-writer', 'test-generator')", +"whenToUse": "A precise, actionable description starting with 'Use this agent when...' that clearly defines the triggering conditions and use cases. Ensure you include examples as described above.", +"systemPrompt": "The complete system prompt that will govern the agent's behavior, written in second person ('You are...', 'You will...') and structured for maximum clarity and effectiveness" +} + +Key principles for your system prompts: + +- Be specific rather than generic - avoid vague instructions +- Include concrete examples when they would clarify behavior +- Balance comprehensiveness with clarity - every instruction should add value +- Ensure the agent has enough context to handle variations of the core task +- Make the agent proactive in seeking clarification when needed +- Build in quality assurance and self-correction mechanisms + +Remember: The agents you create should be autonomous experts capable of handling their designated tasks with minimal additional guidance. Your system prompts are their complete operational manual. diff --git a/packages/opencode/src/agent/subagent-permissions.ts b/packages/opencode/src/agent/subagent-permissions.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1b99b484e76b4d363c5a3af656723ae905dad8a --- /dev/null +++ b/packages/opencode/src/agent/subagent-permissions.ts @@ -0,0 +1,27 @@ +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import type { Agent } from "./agent" + +/** + * Build the `permission` ruleset for a subagent's session when it's spawned + * via the task tool. Combines: + * + * 1. The parent session's deny rules and external_directory rules. + * Parent agent restrictions only govern that agent; the subagent's own + * permissions determine its capabilities. + * 2. Default `todowrite` and `task` denies if the subagent's own ruleset + * doesn't already permit them. + */ +export function deriveSubagentSessionPermission(input: { + parentSessionPermission: PermissionV1.Ruleset + subagent: Agent.Info +}): PermissionV1.Ruleset { + const canTask = input.subagent.permission.some((rule) => rule.permission === "task") + const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite") + return [ + ...input.parentSessionPermission.filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ), + ...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]), + ...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]), + ] +} diff --git a/packages/opencode/src/audio.d.ts b/packages/opencode/src/audio.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b99d097a33f887aac3647b77b58923f7cd4662c --- /dev/null +++ b/packages/opencode/src/audio.d.ts @@ -0,0 +1,14 @@ +declare module "*.wav" { + const file: string + export default file +} + +declare module "*.mp3" { + const file: string + export default file +} + +declare module "*.wasm" { + const file: string + export default file +} diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a133e88498d5b2c3b274f3e7eb7a1d63f59a7851 --- /dev/null +++ b/packages/opencode/src/auth/index.ts @@ -0,0 +1,97 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import path from "path" +import { Effect, Layer, Record, Result, Schema, Context } from "effect" +import { NonNegativeInt } from "@opencode-ai/core/schema" +import { Global } from "@opencode-ai/core/global" +import { FSUtil } from "@opencode-ai/core/fs-util" + +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" + +const file = path.join(Global.Path.data, "auth.json") + +const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) + +export class Oauth extends Schema.Class("OAuth")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, + accountId: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String), +}) {} + +export class Api extends Schema.Class("ApiAuth")({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export class WellKnown extends Schema.Class("WellKnownAuth")({ + type: Schema.Literal("wellknown"), + key: Schema.String, + token: Schema.String, +}) {} + +export const Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" }) +export type Info = Schema.Schema.Type + +export class AuthError extends Schema.TaggedErrorClass()("AuthError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +export interface Interface { + readonly get: (providerID: string) => Effect.Effect + readonly all: () => Effect.Effect, AuthError> + readonly set: (key: string, info: Info) => Effect.Effect + readonly remove: (key: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Auth") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* FSUtil.Service + const decode = Schema.decodeUnknownOption(Info) + + const all = Effect.fn("Auth.all")(function* () { + if (process.env.OPENCODE_AUTH_CONTENT) { + try { + return JSON.parse(process.env.OPENCODE_AUTH_CONTENT) + } catch (err) {} + } + + const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record + return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) + }) + + const get = Effect.fn("Auth.get")(function* (providerID: string) { + return (yield* all())[providerID] + }) + + const set = Effect.fn("Auth.set")(function* (key: string, info: Info) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + if (norm !== key) delete data[key] + delete data[norm + "/"] + yield* fsys + .writeJson(file, { ...data, [norm]: info }, 0o600) + .pipe(Effect.mapError(fail("Failed to write auth data"))) + }) + + const remove = Effect.fn("Auth.remove")(function* (key: string) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + delete data[key] + delete data[norm] + yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data"))) + }) + + return Service.of({ get, all, set, remove }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node] }) + +export * as Auth from "." diff --git a/packages/opencode/src/background/job.ts b/packages/opencode/src/background/job.ts new file mode 100644 index 0000000000000000000000000000000000000000..261f5ad02bed9939d3a7e64e6c12adbe39f7d237 --- /dev/null +++ b/packages/opencode/src/background/job.ts @@ -0,0 +1,37 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { BackgroundJob as CoreBackgroundJob } from "@opencode-ai/core/background-job" +import { InstanceState } from "@/effect/instance-state" +import { Effect, Layer } from "effect" + +export { + Service, + type ExtendInput, + type Info, + type Interface, + type StartInput, + type Status, + type WaitInput, + type WaitResult, +} from "@opencode-ai/core/background-job" + +/** Keeps the legacy service instance-scoped while sharing the core registry engine. */ +const layer = Layer.effect( + CoreBackgroundJob.Service, + Effect.gen(function* () { + const state = yield* InstanceState.make(() => CoreBackgroundJob.make) + return CoreBackgroundJob.Service.of({ + list: () => InstanceState.useEffect(state, (jobs) => jobs.list()), + get: (id) => InstanceState.useEffect(state, (jobs) => jobs.get(id)), + start: (input) => InstanceState.useEffect(state, (jobs) => jobs.start(input)), + extend: (input) => InstanceState.useEffect(state, (jobs) => jobs.extend(input)), + wait: (input) => InstanceState.useEffect(state, (jobs) => jobs.wait(input)), + waitForPromotion: (id) => InstanceState.useEffect(state, (jobs) => jobs.waitForPromotion(id)), + promote: (id) => InstanceState.useEffect(state, (jobs) => jobs.promote(id)), + cancel: (id) => InstanceState.useEffect(state, (jobs) => jobs.cancel(id)), + }) + }), +) + +export const node = LayerNode.make({ service: CoreBackgroundJob.Service, layer, deps: [] }) + +export * as BackgroundJob from "./job" diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts new file mode 100644 index 0000000000000000000000000000000000000000..3cfd453624c14a203cb40b62ede6eddc7a209740 --- /dev/null +++ b/packages/opencode/src/bus/global.ts @@ -0,0 +1,22 @@ +import { EventEmitter } from "events" +import { Identifier } from "@/id/id" + +export type GlobalEvent = { + directory?: string + project?: string + workspace?: string + payload: any +} + +class GlobalBusEmitter extends EventEmitter<{ + event: [GlobalEvent] +}> { + override emit(eventName: "event", event: GlobalEvent): boolean { + if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) { + event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending") + } + return super.emit(eventName, event) + } +} + +export const GlobalBus = new GlobalBusEmitter() diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts new file mode 100644 index 0000000000000000000000000000000000000000..2308c29199baaa57582298982fadf6e697a31944 --- /dev/null +++ b/packages/opencode/src/cli/bootstrap.ts @@ -0,0 +1,11 @@ +import { InstanceRuntime } from "../project/instance-runtime" +import { context } from "../project/instance-context" + +export async function bootstrap(directory: string, cb: () => Promise) { + const ctx = await InstanceRuntime.load({ directory }) + try { + return await context.provide(ctx, cb) + } finally { + await InstanceRuntime.disposeInstance(ctx) + } +} diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ac2bd3555835090aacc878d3b0ab14959218a94 --- /dev/null +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -0,0 +1,96 @@ +import type { Argv } from "yargs" +import { Effect, Schema } from "effect" +import type { AppServices } from "@/effect/app-runtime" +import type { InstanceStore } from "@/project/instance-store" +import { cmd, type WithDoubleDash } from "./cmd/cmd" + +/** + * User-visible command failure. Throw via `fail("...")` from an effectCmd handler + * to surface a printed message + non-zero exit. Recognised by the global error + * formatter in `src/cli/error.ts` (FormatError), so the existing top-level + * catch + cleanup in `src/index.ts` runs normally. + */ +export class CliError extends Schema.TaggedErrorClass()("CliError", { + message: Schema.String, + exitCode: Schema.optional(Schema.Number), +}) {} + +export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode })) + +interface EffectCmdOpts { + command: string | readonly string[] + aliases?: string | readonly string[] + describe: string | false + builder?: (yargs: Argv) => Argv + /** + * Whether the command needs a project InstanceContext. Defaults to true. + * + * `true` (default): wraps the handler in `InstanceStore.Service.provide({directory})` + * so `InstanceRef` resolves to a loaded `InstanceContext`. Auto-disposes via + * `Effect.ensuring(store.dispose(ctx))` on every Exit (matches the legacy + * `bootstrap()` finally-disposal). Runs InstanceBootstrap (config + plugin + * init + LSP/File/etc forks) eagerly. + * + * `false`: skip the instance entirely. Saves the InstanceBootstrap work and + * suppresses the `server.instance.disposed` IPC event. The handler runs + * directly under AppRuntime — it can yield any `AppServices` but must not + * yield `InstanceRef` (it'd be undefined, causing a defect). + * + * Function form: `(args) => boolean` decides per-invocation. Useful for + * commands like `run --attach ` where one flag flips between local + * (needs instance) and remote (doesn't). + * + * Use `false` for commands that don't read project state (e.g. `models`, + * `serve`, `web`, `account`, `db`, `upgrade`). + */ + instance?: boolean | ((args: Args) => boolean) + /** Defaults to process.cwd(). Override for commands that take a directory positional. */ + directory?: (args: Args) => string + handler: (args: WithDoubleDash) => Effect.Effect +} + +/** + * Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is + * an `Effect` with `InstanceRef` provided and any `AppServices` yieldable. + * + * The handler is wrapped in `Effect.ensuring(store.dispose(ctx))` so the loaded + * InstanceContext is disposed (runDisposers + IPC `server.instance.disposed`) + * on every Exit — success, typed failure, defect, or interruption. Matches the + * legacy `bootstrap()` finally-disposal semantics without per-handler boilerplate. + * + * Errors propagate to the existing top-level handler in `src/index.ts`; use + * `fail("...")` for user-visible domain failures (clean exit, formatted message). + * + * Handlers are typically `Effect.fn("Cli.")(function*(args) { ... })`, + * which adds a named tracing span per CLI invocation. Once all commands use + * `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's + * `Command.make(...)` won't touch any handler bodies. + */ +export const effectCmd = (opts: EffectCmdOpts) => + cmd<{}, Args>({ + command: opts.command, + aliases: opts.aliases, + describe: opts.describe, + builder: opts.builder as never, + async handler(rawArgs) { + const { AppRuntime } = await import("@/effect/app-runtime") + // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. + const args = rawArgs as unknown as WithDoubleDash + const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false + if (!useInstance) { + await AppRuntime.runPromise(opts.handler(args)) + return + } + const { InstanceStore } = await import("@/project/instance-store") + const { InstanceRef } = await import("@/effect/instance-ref") + const directory = opts.directory?.(args) ?? process.cwd() + const { store, ctx } = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))), + ) + try { + await AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))) + } finally { + await AppRuntime.runPromise(store.dispose(ctx)) + } + }, + }) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts new file mode 100644 index 0000000000000000000000000000000000000000..407547e4e5a3c18ac1a96e524a3fba3fc916ca79 --- /dev/null +++ b/packages/opencode/src/cli/error.ts @@ -0,0 +1,130 @@ +import { NamedError } from "@opencode-ai/core/util/error" +import { errorFormat } from "@/util/error" +import { isRecord } from "@/util/record" + +type ConfigIssue = { message: string; path: string[] } + +function isTaggedError(error: unknown, tag: string): error is Record { + return isRecord(error) && error._tag === tag +} + +function configData(input: unknown, tag: string): Record | undefined { + if (!isRecord(input)) return undefined + if (input.name === tag && isRecord(input.data)) return input.data + if (input._tag === tag) return input + return undefined +} + +function stringField(input: Record, key: string): string | undefined { + return typeof input[key] === "string" ? input[key] : undefined +} + +function configIssues(input: Record): ConfigIssue[] { + return Array.isArray(input.issues) + ? input.issues.filter((issue): issue is ConfigIssue => { + if (!isRecord(issue)) return false + return ( + typeof issue.message === "string" && + Array.isArray(issue.path) && + issue.path.every((x) => typeof x === "string") + ) + }) + : [] +} + +export function FormatError(input: unknown): string | undefined { + if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) { + const formatted = FormatError(input.cause.body) + if (formatted) return formatted + } + + // CliError: domain failure surfaced from an effectCmd handler via fail("...") + if (isTaggedError(input, "CliError")) { + if (typeof input.exitCode === "number") process.exitCode = input.exitCode + return stringField(input, "message") ?? "" + } + + // MCPFailed: { name: string } + if (NamedError.hasName(input, "MCPFailed")) { + const data = isRecord(input) && isRecord(input.data) ? stringField(input.data, "name") : undefined + return `MCP server "${data}" failed. Note, opencode does not support MCP authentication yet.` + } + + // AccountServiceError, AccountTransportError: TaggedErrorClass + if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) { + return stringField(input, "message") ?? "" + } + + // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] } + const providerModelNotFound = configData(input, "ProviderModelNotFoundError") + if (providerModelNotFound) { + const suggestions = Array.isArray(providerModelNotFound.suggestions) + ? providerModelNotFound.suggestions.filter((x) => typeof x === "string") + : [] + return [ + `Model not found: ${stringField(providerModelNotFound, "providerID")}/${stringField(providerModelNotFound, "modelID")}`, + ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), + `Try: \`opencode models\` to list available models`, + `Or check your config (opencode.json) provider/model names`, + ].join("\n") + } + + // ProviderInitError: { providerID: string } + const providerInit = configData(input, "ProviderInitError") + if (providerInit) { + return `Failed to initialize provider "${stringField(providerInit, "providerID")}". Check credentials and configuration.` + } + + // ConfigJsonError: { path: string, message?: string } + const configJson = configData(input, "ConfigJsonError") + if (configJson) { + const message = stringField(configJson, "message") + return `Config file at ${stringField(configJson, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "") + } + + // ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string } + const configDirectoryTypo = configData(input, "ConfigDirectoryTypoError") + if (configDirectoryTypo) { + return `Directory "${stringField(configDirectoryTypo, "dir")}" in ${stringField(configDirectoryTypo, "path")} is not valid. Rename the directory to "${stringField(configDirectoryTypo, "suggestion")}" or remove it. This is a common typo.` + } + + // ConfigFrontmatterError: { message: string } + const configFrontmatter = configData(input, "ConfigFrontmatterError") + if (configFrontmatter) { + return stringField(configFrontmatter, "message") ?? "" + } + + // ConfigRemoteAuthError: { url: string, remote: string } + const remoteAuth = configData(input, "ConfigRemoteAuthError") + if (remoteAuth) { + const url = stringField(remoteAuth, "url") + const remote = stringField(remoteAuth, "remote") + return [ + `Failed to load remote config${remote ? ` from ${remote}` : ""}: the server returned a login page instead of JSON.`, + `Authentication is missing or has expired (the endpoint is likely behind an SSO or identity-aware proxy).`, + ...(url ? [`Run \`opencode auth login ${url}\` to re-authenticate.`] : []), + ].join("\n") + } + + // ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> } + const configInvalid = configData(input, "ConfigInvalidError") + if (configInvalid) { + const path = stringField(configInvalid, "path") + const message = stringField(configInvalid, "message") + const issues = configIssues(configInvalid) + return [ + `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""), + ...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")), + ].join("\n") + } + + // UICancelledError: user cancelled an interactive CLI prompt + if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) { + return "" + } + return undefined +} + +export function FormatUnknownError(input: unknown): string { + return errorFormat(input) +} diff --git a/packages/opencode/src/cli/heap.ts b/packages/opencode/src/cli/heap.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8ec8f1bd0fe98ab6573ed9f061399a75d15e519 --- /dev/null +++ b/packages/opencode/src/cli/heap.ts @@ -0,0 +1,45 @@ +import path from "path" +import { writeHeapSnapshot } from "node:v8" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +const MINUTE = 60_000 +const LIMIT = 2 * 1024 * 1024 * 1024 + +let timer: Timer | undefined +let lock = false +let armed = true + +export function start() { + if (!Flag.OPENCODE_AUTO_HEAP_SNAPSHOT) return + if (timer) return + + const run = async () => { + if (lock) return + + const stat = process.memoryUsage() + if (stat.rss <= LIMIT) { + armed = true + return + } + if (!armed) return + + lock = true + armed = false + const file = path.join( + Global.Path.log, + `heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`, + ) + await Promise.resolve() + .then(() => writeHeapSnapshot(file)) + .catch(() => {}) + + lock = false + } + + timer = setInterval(() => { + void run() + }, MINUTE) + timer.unref?.() +} + +export * as Heap from "./heap" diff --git a/packages/opencode/src/cli/logo.ts b/packages/opencode/src/cli/logo.ts new file mode 100644 index 0000000000000000000000000000000000000000..9117d38661c9b6731b2daa62496251bdc48b96ca --- /dev/null +++ b/packages/opencode/src/cli/logo.ts @@ -0,0 +1 @@ +export * from "@opencode-ai/tui/logo" diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts new file mode 100644 index 0000000000000000000000000000000000000000..22040ed41015849f47dc39c0340e3e0986116e85 --- /dev/null +++ b/packages/opencode/src/cli/network.ts @@ -0,0 +1,80 @@ +import type { Argv, InferredOptionTypes } from "yargs" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import type { Config } from "@/config/config" +import { Effect } from "effect" + +const options = { + port: { + type: "number" as const, + describe: "port to listen on", + default: 0, + }, + hostname: { + type: "string" as const, + describe: "hostname to listen on", + default: "127.0.0.1", + }, + mdns: { + type: "boolean" as const, + describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)", + default: false, + }, + "mdns-domain": { + type: "string" as const, + describe: "custom domain name for mDNS service (default: opencode.local)", + default: "opencode.local", + }, + cors: { + type: "string" as const, + array: true, + describe: "additional domains to allow for CORS", + default: [] as string[], + }, +} + +export type NetworkOptions = InferredOptionTypes + +export function withNetworkOptions(yargs: Argv) { + return yargs.options(options) +} + +export function hasArg(name: string) { + return networkArgs().some((arg) => arg === name || arg.startsWith(name + "=")) +} + +function hasBooleanArg(name: string) { + return networkArgs().some( + (arg) => arg === name || arg === name + "=true" || arg === name + "=false" || arg === "--no-" + name.slice(2), + ) +} + +function networkArgs() { + const separator = process.argv.indexOf("--") + return process.argv.slice(2, separator === -1 ? undefined : separator) +} + +export const resolveNetworkOptions = Effect.fn("Cli.resolveNetworkOptions")(function* (args: NetworkOptions) { + const { Config } = yield* Effect.promise(() => import("@/config/config")) + const config = yield* Config.Service.use((cfg) => cfg.getGlobal()) + return resolveNetworkOptionsNoConfig(args, config) +}) + +export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: ConfigV1.Info) { + const portExplicitlySet = hasArg("--port") + const hostnameExplicitlySet = hasArg("--hostname") + const mdnsExplicitlySet = hasBooleanArg("--mdns") + const mdnsDomainExplicitlySet = hasArg("--mdns-domain") + const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) + const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"]) + const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) + const hostname = hostnameExplicitlySet + ? args.hostname + : mdns && !config?.server?.hostname + ? "0.0.0.0" + : (config?.server?.hostname ?? args.hostname) + const configCors = config?.server?.cors ?? [] + const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] + const cors = [...configCors, ...argsCors] + + return { hostname, port, mdns, mdnsDomain, cors } +} diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ad6495cf10bfe604bff172359d682b3fe9d4dee --- /dev/null +++ b/packages/opencode/src/cli/ui.ts @@ -0,0 +1,132 @@ +import { EOL } from "os" +import { Schema } from "effect" +import { logo as glyphs } from "./logo" + +const wordmark = [ + `⠀ ▄ `, + `█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█`, + `█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀`, + `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`, +] + +export class CancelledError extends Schema.TaggedErrorClass()("UICancelledError", {}) {} + +export const Style = { + TEXT_HIGHLIGHT: "\x1b[96m", + TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m", + TEXT_DIM: "\x1b[90m", + TEXT_DIM_BOLD: "\x1b[90m\x1b[1m", + TEXT_NORMAL: "\x1b[0m", + TEXT_NORMAL_BOLD: "\x1b[1m", + TEXT_WARNING: "\x1b[93m", + TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m", + TEXT_DANGER: "\x1b[91m", + TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m", + TEXT_SUCCESS: "\x1b[92m", + TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m", + TEXT_INFO: "\x1b[94m", + TEXT_INFO_BOLD: "\x1b[94m\x1b[1m", +} + +export function println(...message: string[]) { + print(...message) + process.stderr.write(EOL) +} + +export function print(...message: string[]) { + blank = false + process.stderr.write(message.join(" ")) +} + +let blank = false +export function empty() { + if (blank) return + println("" + Style.TEXT_NORMAL) + blank = true +} + +export function logo(pad?: string) { + if (!process.stdout.isTTY && !process.stderr.isTTY) { + const result = [] + for (const row of wordmark) { + if (pad) result.push(pad) + result.push(row) + result.push(EOL) + } + return result.join("").trimEnd() + } + + const result: string[] = [] + const reset = "\x1b[0m" + const left = { + fg: "\x1b[90m", + shadow: "\x1b[38;5;235m", + bg: "\x1b[48;5;235m", + } + const right = { + fg: reset, + shadow: "\x1b[38;5;238m", + bg: "\x1b[48;5;238m", + } + const gap = " " + const draw = (line: string, fg: string, shadow: string, bg: string) => { + const parts: string[] = [] + for (const char of line) { + if (char === "_") { + parts.push(bg, " ", reset) + continue + } + if (char === "^") { + parts.push(fg, bg, "▀", reset) + continue + } + if (char === "~") { + parts.push(shadow, "▀", reset) + continue + } + if (char === " ") { + parts.push(" ") + continue + } + parts.push(fg, char, reset) + } + return parts.join("") + } + glyphs.left.forEach((row, index) => { + if (pad) result.push(pad) + result.push(draw(row, left.fg, left.shadow, left.bg)) + result.push(gap) + const other = glyphs.right[index] ?? "" + result.push(draw(other, right.fg, right.shadow, right.bg)) + result.push(EOL) + }) + return result.join("").trimEnd() +} + +export async function input(prompt: string): Promise { + const readline = require("readline") + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise((resolve) => { + rl.question(prompt, (answer: string) => { + rl.close() + resolve(answer.trim()) + }) + }) +} + +export function error(message: string) { + if (message.startsWith("Error: ")) { + message = message.slice("Error: ".length) + } + println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message) +} + +export function markdown(text: string): string { + return text +} + +export * as UI from "./ui" diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts new file mode 100644 index 0000000000000000000000000000000000000000..62b230a6336471aa735f82cfb24a4ae2b0c8c31a --- /dev/null +++ b/packages/opencode/src/cli/upgrade.ts @@ -0,0 +1,53 @@ +import { Config } from "@/config/config" +import { AppRuntime } from "@/effect/app-runtime" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Installation } from "@/installation" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { GlobalBus } from "@/bus/global" + +export async function upgrade() { + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) + if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return + const method = await Installation.method() + const latest = await Installation.latest(method).catch(() => {}) + if (!latest) return + + if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) { + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Installation.Event.UpdateAvailable.type, + properties: { version: latest }, + }, + }) + return + } + + if (InstallationVersion === latest) return + + const kind = Installation.getReleaseType(InstallationVersion, latest) + + if (config.autoupdate === "notify" || kind !== "patch") { + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Installation.Event.UpdateAvailable.type, + properties: { version: latest }, + }, + }) + return + } + + if (method === "unknown") return + await Installation.upgrade(method, latest) + .then(() => + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Installation.Event.Updated.type, + properties: { version: latest }, + }, + }), + ) + .catch(() => {}) +} diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..057754cd9ef879d18efb5934232eec079732d817 --- /dev/null +++ b/packages/opencode/src/command/index.ts @@ -0,0 +1,177 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import path from "path" +import { InstanceState } from "@/effect/instance-state" +import { EffectBridge } from "@/effect/bridge" +import type { InstanceContext } from "@/project/instance-context" +import { Effect, Layer, Context, Schema } from "effect" +import { Config } from "@/config/config" +import { MCP } from "../mcp" +import { Skill } from "../skill" +import PROMPT_INITIALIZE from "./template/initialize.txt" +import PROMPT_REVIEW from "./template/review.txt" +import { LegacyEvent } from "@opencode-ai/schema/legacy-event" + +type State = { + commands: Record +} + +export const Event = { + Executed: LegacyEvent.CommandExecuted, +} + +export const Info = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])), + // Some command templates are lazy promises from MCP prompt resolution. + template: Schema.Unknown, + subtask: Schema.optional(Schema.Boolean), + hints: Schema.Array(Schema.String), +}).annotate({ identifier: "Command" }) + +export type Info = Omit, "template"> & { template: Promise | string } + +export function hints(template: string) { + const result: string[] = [] + const numbered = template.match(/\$\d+/g) + if (numbered) { + for (const match of [...new Set(numbered)].sort()) result.push(match) + } + if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS") + return result +} + +export const Default = { + INIT: "init", + REVIEW: "review", +} as const + +export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly list: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Command") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const mcp = yield* MCP.Service + const skill = yield* Skill.Service + + const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { + const cfg = yield* config.get() + const bridge = yield* EffectBridge.make() + const commands: Record = {} + + commands[Default.INIT] = { + name: Default.INIT, + description: "guided AGENTS.md setup", + source: "command", + get template() { + return PROMPT_INITIALIZE.replace("${path}", ctx.worktree) + }, + hints: hints(PROMPT_INITIALIZE), + } + commands[Default.REVIEW] = { + name: Default.REVIEW, + description: "review changes [commit|branch|pr], defaults to uncommitted", + source: "command", + get template() { + return PROMPT_REVIEW.replace("${path}", ctx.worktree) + }, + subtask: true, + hints: hints(PROMPT_REVIEW), + } + + for (const [name, command] of Object.entries(cfg.command ?? {})) { + commands[name] = { + name, + agent: command.agent, + model: command.model, + description: command.description, + source: "command", + get template() { + return command.template + }, + subtask: command.subtask, + hints: hints(command.template), + } + } + + for (const [name, prompt] of Object.entries(yield* mcp.prompts())) { + commands[name] = { + name, + source: "mcp", + description: prompt.description, + get template() { + return bridge.promise( + mcp + .getPrompt( + prompt.client, + prompt.name, + prompt.arguments + ? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`])) + : {}, + ) + .pipe( + Effect.map( + (template) => + template?.messages + .map((message) => (message.content.type === "text" ? message.content.text : "")) + .join("\n") || "", + ), + ), + ) + }, + hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], + } + } + + for (const item of yield* skill.all()) { + if (commands[item.name]) continue + const dir = item.location === "" ? undefined : path.dirname(item.location) + commands[item.name] = { + name: item.name, + description: item.description, + source: "skill", + get template() { + if (!dir) return item.content + return [ + item.content, + "", + `Base directory for this skill: ${dir}`, + "Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.", + ].join("\n") + }, + hints: [], + } + } + + return { + commands, + } + }) + + const state = yield* InstanceState.make((ctx) => init(ctx)) + + const get = Effect.fn("Command.get")(function* (name: string) { + const s = yield* InstanceState.get(state) + return s.commands[name] + }) + + const list = Effect.fn("Command.list")(function* () { + const s = yield* InstanceState.get(state) + return Object.values(s.commands) + }) + + return Service.of({ get, list }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node, MCP.node, Skill.node] }) + +export * as Command from "." diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..a74450ce076fdf0b516e367d22db6d849e899ecf --- /dev/null +++ b/packages/opencode/src/config/agent.ts @@ -0,0 +1,59 @@ +export * as ConfigAgent from "./agent" + +import path from "path" +import { Exit, Schema } from "effect" +import { Glob } from "@opencode-ai/core/util/glob" +import { ConfigAgentV1 } from "@opencode-ai/core/v1/config/agent" +import { configEntryNameFromPath } from "./entry-name" +import * as ConfigMarkdown from "./markdown" +import { ConfigParse } from "./parse" + +export async function load(dir: string) { + const result: Record = {} + for (const item of await Glob.scan("{agent,agents}/**/*.md", { + cwd: dir, + absolute: true, + dot: true, + symlink: true, + })) { + const md = await ConfigMarkdown.parse(item).catch(() => undefined) + if (!md) continue + + const name = configEntryNameFromPath(path.relative(dir, item), ["agent/", "agents/"]) + + const config = { + name, + ...md.data, + prompt: md.content.trim(), + } + result[config.name] = ConfigParse.schema(ConfigAgentV1.Info, config, item) + } + return result +} + +export async function loadMode(dir: string) { + const result: Record = {} + for (const item of await Glob.scan("{mode,modes}/*.md", { + cwd: dir, + absolute: true, + dot: true, + symlink: true, + })) { + const md = await ConfigMarkdown.parse(item).catch(() => undefined) + if (!md) continue + + const config = { + name: configEntryNameFromPath(path.relative(dir, item), ["mode/", "modes/"]), + ...md.data, + prompt: md.content.trim(), + } + const parsed = Schema.decodeUnknownExit(ConfigAgentV1.Info)(config, { errors: "all", propertyOrder: "original" }) + if (Exit.isSuccess(parsed)) { + result[config.name] = { + ...parsed.value, + mode: "primary" as const, + } + } + } + return result +} diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts new file mode 100644 index 0000000000000000000000000000000000000000..f32e146d91bdf17cdd0cbd3dda567e3ab3925a0b --- /dev/null +++ b/packages/opencode/src/config/command.ts @@ -0,0 +1,39 @@ +export * as ConfigCommand from "./command" + +import path from "path" +import { Cause, Exit, Schema } from "effect" +import { Glob } from "@opencode-ai/core/util/glob" +import { ConfigCommandV1 } from "@opencode-ai/core/v1/config/command" +import { configEntryNameFromPath } from "./entry-name" +import { InvalidError } from "@opencode-ai/core/v1/config/error" +import * as ConfigMarkdown from "./markdown" + +const decodeInfo = Schema.decodeUnknownExit(ConfigCommandV1.Info) + +export async function load(dir: string) { + const result: Record = {} + for (const item of await Glob.scan("{command,commands}/**/*.md", { + cwd: dir, + absolute: true, + dot: true, + symlink: true, + })) { + const md = await ConfigMarkdown.parse(item).catch(() => undefined) + if (!md) continue + + const name = configEntryNameFromPath(path.relative(dir, item), ["command/", "commands/"]) + + const config = { + name, + ...md.data, + template: md.content.trim(), + } + const parsed = decodeInfo(config, { errors: "all", propertyOrder: "original" }) + if (Exit.isSuccess(parsed)) { + result[config.name] = parsed.value + continue + } + throw new InvalidError({ path: item, message: Cause.pretty(parsed.cause) }, { cause: Cause.squash(parsed.cause) }) + } + return result +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e10b67fe703609a3ccf243ebe1801bf338961d3 --- /dev/null +++ b/packages/opencode/src/config/config.ts @@ -0,0 +1,701 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import path from "path" +import { pathToFileURL } from "url" +import os from "os" +import { mergeDeep } from "remeda" +import { Global } from "@opencode-ai/core/global" +import fsNode from "fs/promises" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Auth } from "../auth" +import { Env } from "../env" +import { applyEdits, modify } from "jsonc-parser" +import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" +import { existsSync } from "fs" +import { Account } from "@/account/account" +import { isRecord } from "@/util/record" +import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { InstanceState } from "@/effect/instance-state" +import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { containsPath, type InstanceContext } from "../project/instance-context" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { RemoteAuthError } from "@opencode-ai/core/v1/config/error" +import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" +import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" +import { ConfigAgent } from "./agent" +import { ConfigCommand } from "./command" +import { ConfigManaged } from "./managed" +import { ConfigParse } from "./parse" +import { ConfigPaths } from "./paths" +import { ConfigPlugin } from "./plugin" +import { ConfigVariable } from "./variable" +import { ConfigV2Compat } from "./v2-compat" +import { Npm } from "@opencode-ai/core/npm" +import { withTransientReadRetry } from "@/util/effect-http-client" + +// Custom merge function that concatenates array fields instead of replacing them +// Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here. +function mergeConfig(target: Info, source: Info): Info { + return mergeDeep(target, source) as Info +} + +function mergeConfigConcatArrays(target: Info, source: Info): Info { + const merged = mergeConfig(target, source) + if (target.instructions && source.instructions) { + merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) + } + return merged +} + +function normalizeLoadedConfig(data: unknown) { + if (!isRecord(data)) return data + const copy = { ...data } + const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy + if (!hadLegacy) return copy + delete copy.theme + delete copy.keybinds + delete copy.tui + return copy +} + +async function substituteWellKnownRemoteConfig(input: { + value: unknown + dir: string + source: string + env: Record +}) { + if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined + + const url = await ConfigVariable.substitute({ + text: input.value.url, + type: "virtual", + dir: input.dir, + source: input.source, + env: input.env, + }) + const headers = isRecord(input.value.headers) + ? Object.fromEntries( + await Promise.all( + Object.entries(input.value.headers) + .filter((entry): entry is [string, string] => typeof entry[1] === "string") + .map(async ([key, value]) => [ + key, + await ConfigVariable.substitute({ + text: value, + type: "virtual", + dir: input.dir, + source: input.source, + env: input.env, + }), + ]), + ), + ) + : undefined + + return { url, headers } +} + +async function resolveLoadedPlugins(config: T, filepath: string) { + if (!config.plugin) return config + for (let i = 0; i < config.plugin.length; i++) { + // Normalize path-like plugin specs while we still know which config file declared them. + // This prevents `./plugin.ts` from being reinterpreted relative to some later merge location. + config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], filepath) + } + return config +} + +type Info = ConfigV1.Info & { + // plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together + // with the file and scope it came from so later runtime code can make location-sensitive decisions. + plugin_origins?: ConfigPlugin.Origin[] +} + +type State = { + config: Info + directories: string[] + deps: Fiber.Fiber[] + consoleState: ConsoleState +} + +export interface Interface { + readonly get: () => Effect.Effect + readonly getGlobal: () => Effect.Effect + readonly getConsoleState: () => Effect.Effect + readonly update: (config: Info) => Effect.Effect + readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }> + readonly invalidate: () => Effect.Effect + readonly directories: () => Effect.Effect + readonly waitForDependencies: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Config") {} + +export const use = serviceUse(Service) + +function globalConfigFile() { + const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) => + path.join(Global.Path.config, file), + ) + for (const file of candidates) { + if (existsSync(file)) return file + } + return candidates[0] +} + +function patchJsonc(input: string, patch: unknown, path: string[] = []): string { + if (!isRecord(patch)) { + const edits = modify(input, path, patch, { + formattingOptions: { + insertSpaces: true, + tabSize: 2, + }, + }) + return applyEdits(input, edits) + } + + return Object.entries(patch).reduce((result, [key, value]) => patchJsonc(result, value, [...path, key]), input) +} + +function writable(info: Info) { + const { plugin_origins: _plugin_origins, ...next } = info + return next +} + +function writableGlobal(info: Info) { + const next = writable(info) + // When a user changes config from a value back to default in the Desktop app, we don't want to leave a blank `"shell": "",` key + if ("shell" in next && next.shell === "") return { ...next, shell: undefined } + return next +} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const authSvc = yield* Auth.Service + const accountSvc = yield* Account.Service + const env = yield* Env.Service + const npmSvc = yield* Npm.Service + const http = yield* HttpClient.HttpClient + + const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) + + const decodeConfig = Effect.fnUntraced(function* (input: unknown, source: string) { + const result = ConfigV2Compat.lower(normalizeLoadedConfig(input), source) + yield* Effect.forEach(result.diagnostics, (diagnostic) => + Effect.logWarning("configuration compatibility diagnostic", { + source, + path: diagnostic.path, + kind: diagnostic.kind, + action: diagnostic.message, + }), + ) + return ConfigParse.schema(ConfigV1.Info, result.value, source) + }) + + const fetchRemoteJson = Effect.fnUntraced(function* ( + url: string, + headers: Record | undefined, + schema: S, + loginOrigin: string, + ) { + const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http)) + .execute( + HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})), + ) + .pipe( + Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))), + ) + const body = yield* response.text.pipe( + Effect.catch((error) => Effect.die(new Error(`failed to read remote config from ${url}: ${String(error)}`))), + ) + // An auth proxy can answer with an HTML login page at HTTP 200 (passes filterStatusOk); treat it as a re-auth error, not a decode failure. + const contentType = (response.headers["content-type"] ?? "").toLowerCase() + if (contentType.includes("html") || /^\s* Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))), + ) + }) + + const loadConfig = Effect.fnUntraced(function* ( + text: string, + options: { path: string } | { dir: string; source: string }, + env?: Record, + ) { + const source = "path" in options ? options.path : options.source + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute( + "path" in options + ? { text, type: "path", path: options.path, env } + : { text, type: "virtual", ...options, env }, + ), + ) + const parsed = ConfigParse.jsonc(expanded, source) + const data = yield* decodeConfig(parsed, source) + if (!("path" in options)) return data + + yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) + if (!data.$schema) { + data.$schema = "https://opencode.ai/config.json" + const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') + yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) + } + return data + }) + + const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record) { + yield* Effect.logInfo("loading", { path: filepath }) + const text = yield* readConfigFile(filepath) + if (!text) return {} as Info + return yield* loadConfig(text, { path: filepath }, env) + }) + + const loadGlobal = Effect.fnUntraced(function* (env?: Record) { + let result: Info = {} + // Seed the default global config with the schema for editor completion, but avoid writing when the user + // explicitly routes config through env-provided paths or content. + if (!Flag.OPENCODE_CONFIG && !Flag.OPENCODE_CONFIG_DIR && !Flag.OPENCODE_CONFIG_CONTENT) { + const file = globalConfigFile() + if (!existsSync(file)) { + yield* fs + .writeWithDirs(file, JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2)) + .pipe(Effect.catch(() => Effect.void)) + } + } + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env)) + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env)) + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env)) + + const legacy = path.join(Global.Path.config, "config") + if (existsSync(legacy)) { + yield* Effect.promise(() => + import(pathToFileURL(legacy).href, { with: { type: "toml" } }) + .then(async (mod) => { + const { provider, model, ...rest } = mod.default + if (provider && model) result.model = `${provider}/${model}` + result["$schema"] = "https://opencode.ai/config.json" + result = mergeConfig(result, rest) + await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) + await fsNode.unlink(legacy) + }) + .catch(() => {}), + ) + } + + return result + }) + + const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL( + loadGlobal().pipe( + Effect.tapError((error) => + Effect.logError("failed to load global config, using defaults", { error: String(error) }), + ), + Effect.orElseSucceed((): Info => ({})), + ), + Duration.infinity, + ) + + const getGlobal = Effect.fn("Config.getGlobal")(function* () { + return yield* cachedGlobal + }) + + const ensureGitignore = Effect.fn("Config.ensureGitignore")(function* (dir: string) { + yield* fs.ensureDir(dir) + const gitignore = path.join(dir, ".gitignore") + const hasIgnore = yield* fs.existsSafe(gitignore) + if (!hasIgnore) { + yield* fs + .writeFileString( + gitignore, + ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), + ) + .pipe( + Effect.catchIf( + (e) => e.reason._tag === "PermissionDenied", + () => Effect.void, + ), + ) + } + }) + + const loadInstanceState = Effect.fn("Config.loadInstanceState")( + function* (ctx: InstanceContext) { + const auth = yield* authSvc.all().pipe(Effect.orDie) + + let result: Info = {} + const authEnv: Record = {} + const consoleManagedProviders = new Set() + let activeOrgName: string | undefined + + const pluginScopeForSource = Effect.fnUntraced(function* (source: string) { + if (source.startsWith("http://") || source.startsWith("https://")) return "global" + if (source === "OPENCODE_CONFIG_CONTENT") return "local" + if (containsPath(source, ctx)) return "local" + return "global" + }) + + const mergePluginOrigins = Effect.fnUntraced(function* ( + source: string, + // mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step + // is attached. + list: ConfigPluginV1.Spec[] | undefined, + // Scope can be inferred from the source path, but some callers already know whether the config should + // behave as global or local and can pass that explicitly. + kind?: ConfigPlugin.Scope, + ) { + if (!list?.length) return + const hit = kind ?? (yield* pluginScopeForSource(source)) + // Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while + // keeping the winning source/scope metadata for downstream installs, writes, and diagnostics. + const plugins = ConfigPlugin.deduplicatePluginOrigins([ + ...(result.plugin_origins ?? []), + ...list.map((spec) => ({ spec, source, scope: hit })), + ]) + result.plugin = plugins.map((item) => item.spec) + result.plugin_origins = plugins + }) + + const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => { + result = mergeConfigConcatArrays(result, next) + return mergePluginOrigins(source, next.plugin, kind) + } + + for (const [key, value] of Object.entries(auth)) { + if (value.type === "wellknown") { + const url = key.replace(/\/+$/, "") + authEnv[value.key] = value.token + const wellknownURL = `${url}/.well-known/opencode` + yield* Effect.logDebug("fetching remote config", { url: wellknownURL }) + const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, ConfigV1.WellKnown, url) + const remote = yield* Effect.promise(() => + substituteWellKnownRemoteConfig({ + value: wellknown.remote_config, + dir: url, + source: wellknownURL, + env: authEnv, + }), + ) + const fetchedConfig = remote + ? yield* Effect.gen(function* () { + yield* Effect.logDebug("fetching remote config", { url: remote.url }) + const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json, url) + if (isRecord(data) && isRecord(data.config)) return data.config + if (isRecord(data)) return data + return yield* Effect.die( + new Error(`failed to decode remote config from ${remote.url}: expected object`), + ) + }) + : {} + const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig) + if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" + const source = wellknownURL + const next = yield* loadConfig( + JSON.stringify(remoteConfig), + { + dir: path.dirname(source), + source, + }, + authEnv, + ) + yield* merge(source, next, "global") + yield* Effect.logDebug("loaded remote config from well-known", { url }) + } + } + + const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal() + yield* merge(Global.Path.config, global, "global") + + if (Flag.OPENCODE_CONFIG) { + yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv)) + yield* Effect.logDebug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) + } + + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) { + yield* merge(file, yield* loadFile(file, authEnv), "local") + } + } + + result.agent = result.agent || {} + result.mode = result.mode || {} + result.plugin = result.plugin || [] + + const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree) + + if (Flag.OPENCODE_CONFIG_DIR) { + yield* Effect.logDebug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) + } + + const deps: Fiber.Fiber[] = [] + + for (const dir of directories) { + if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { + for (const file of ["opencode.json", "opencode.jsonc"]) { + const source = path.join(dir, file) + yield* Effect.logDebug(`loading config from ${source}`) + yield* merge(source, yield* loadFile(source, authEnv)) + result.agent ??= {} + result.mode ??= {} + result.plugin ??= [] + } + } + + yield* ensureGitignore(dir).pipe(Effect.orDie) + + const dep = yield* npmSvc + .install(dir, { + add: [ + { + name: "@opencode-ai/plugin", + version: InstallationLocal ? undefined : InstallationVersion, + }, + ], + }) + .pipe( + Effect.exit, + Effect.tap((exit) => + Exit.isFailure(exit) + ? Effect.logWarning("background dependency install failed", { dir, error: String(exit.cause) }) + : Effect.void, + ), + Effect.asVoid, + Effect.forkDetach, + ) + deps.push(dep) + + result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir))) + result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir))) + result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir))) + // Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load + // returns normalized Specs and we only need to attach origin metadata here. + const list = yield* Effect.promise(() => ConfigPlugin.load(dir)) + yield* mergePluginOrigins(dir, list) + } + + if (process.env.OPENCODE_CONFIG_CONTENT) { + const source = "OPENCODE_CONFIG_CONTENT" + const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { + dir: ctx.directory, + source, + }) + yield* merge(source, next, "local") + yield* Effect.logDebug("loaded custom config from OPENCODE_CONFIG_CONTENT") + } + + const activeAccount = Option.getOrUndefined( + yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))), + ) + if (activeAccount?.active_org_id) { + const accountID = activeAccount.id + const orgID = activeAccount.active_org_id + const url = activeAccount.url + yield* Effect.gen(function* () { + const [configOpt, tokenOpt] = yield* Effect.all( + [accountSvc.config(accountID, orgID), accountSvc.token(accountID)], + { concurrency: 2 }, + ) + if (Option.isSome(tokenOpt)) { + process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value + yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value) + } + + if (Option.isSome(configOpt)) { + const source = `${url}/api/config` + const next = yield* loadConfig(JSON.stringify(configOpt.value), { + dir: path.dirname(source), + source, + }) + for (const providerID of Object.keys(next.provider ?? {})) { + consoleManagedProviders.add(providerID) + } + yield* merge(source, next, "global") + } + }).pipe( + Effect.withSpan("Config.loadActiveOrgConfig"), + Effect.catch((err) => + Effect.logDebug("failed to fetch remote account config", { + error: err instanceof Error ? err.message : String(err), + }), + ), + ) + } + + const managedDir = ConfigManaged.managedConfigDir() + if (existsSync(managedDir)) { + for (const file of ["opencode.json", "opencode.jsonc"]) { + const source = path.join(managedDir, file) + yield* merge(source, yield* loadFile(source), "global") + } + } + + // macOS managed preferences (.mobileconfig deployed via MDM) override everything + const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences()) + if (managed) { + result = mergeConfigConcatArrays( + result, + yield* loadConfig(managed.text, { + dir: path.dirname(managed.source), + source: managed.source, + }), + ) + } + + for (const [name, mode] of Object.entries(result.mode ?? {})) { + result.agent = mergeDeep(result.agent ?? {}, { + [name]: { + ...mode, + mode: "primary" as const, + }, + }) + } + + if (Flag.OPENCODE_PERMISSION) { + try { + result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) + } catch (err) { + yield* Effect.logWarning("OPENCODE_PERMISSION contains invalid JSON, skipping", { err }) + } + } + + if (result.tools) { + const perms: Record = {} + for (const [tool, enabled] of Object.entries(result.tools)) { + const action: ConfigPermissionV1.Action = enabled ? "allow" : "deny" + if (tool === "write" || tool === "edit" || tool === "patch") { + perms.edit = action + continue + } + perms[tool] = action + } + result.permission = mergeDeep(perms, result.permission ?? {}) + } + + if (!result.username) { + try { + result.username = os.userInfo().username || "user" + } catch (err) { + yield* Effect.logWarning("failed to read system username, using fallback", { err }) + result.username = "user" + } + } + + if (result.autoshare === true && !result.share) { + result.share = "auto" + } + + if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) { + result.compaction = { ...result.compaction, auto: false } + } + if (Flag.OPENCODE_DISABLE_PRUNE) { + result.compaction = { ...result.compaction, prune: false } + } + + return { + config: result, + directories, + deps, + consoleState: { + consoleManagedProviders: Array.from(consoleManagedProviders), + activeOrgName, + switchableOrgCount: 0, + }, + } + }, + Effect.provideService(FSUtil.Service, fs), + ) + + const state = yield* InstanceState.make( + Effect.fn("Config.state")(function* (ctx) { + return yield* loadInstanceState(ctx).pipe(Effect.orDie) + }), + ) + + const get = Effect.fn("Config.get")(function* () { + return yield* InstanceState.use(state, (s) => s.config) + }) + + const directories = Effect.fn("Config.directories")(function* () { + return yield* InstanceState.use(state, (s) => s.directories) + }) + + const getConsoleState = Effect.fn("Config.getConsoleState")(function* () { + return yield* InstanceState.use(state, (s) => s.consoleState) + }) + + const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () { + yield* InstanceState.useEffect(state, (s) => + Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid), + ) + }) + + const update = Effect.fn("Config.update")(function* (config: Info) { + const dir = yield* InstanceState.directory + const file = path.join(dir, "config.json") + const existing = yield* loadFile(file) + const text = yield* readConfigFile(file) + const original = text ? ConfigParse.jsonc(text, file) : writable(existing) + yield* fs + .writeFileString( + file, + JSON.stringify(mergeDeep(isRecord(original) ? original : writable(existing), writable(config)), null, 2), + ) + .pipe(Effect.orDie) + }) + + const invalidate = Effect.fn("Config.invalidate")(function* () { + yield* invalidateGlobal + }) + + const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { + const file = globalConfigFile() + const before = (yield* readConfigFile(file)) ?? "{}" + const patch = writableGlobal(config) + + let next: Info + let changed: boolean + if (!file.endsWith(".jsonc")) { + const existing = ConfigParse.jsonc(before, file) + ConfigParse.schema(ConfigV1.Info, ConfigV2Compat.lower(normalizeLoadedConfig(existing), file).value, file) + const merged = mergeDeep(isRecord(existing) ? existing : {}, patch) + const serialized = JSON.stringify(merged, null, 2) + next = yield* decodeConfig(merged, file) + changed = serialized !== before + if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie) + } else { + const updated = patchJsonc(before, patch) + next = yield* decodeConfig(ConfigParse.jsonc(updated, file), file) + changed = updated !== before + if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) + } + + if (changed) yield* invalidate() + return { info: next, changed } + }) + + return Service.of({ + get, + getGlobal, + getConsoleState, + update, + updateGlobal, + invalidate, + directories, + waitForDependencies, + }) + }), +) + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient], +}) + +export * as Config from "./config" diff --git a/packages/opencode/src/config/entry-name.ts b/packages/opencode/src/config/entry-name.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0a788e663a486e3ef1e35337e4e259c71e803ab --- /dev/null +++ b/packages/opencode/src/config/entry-name.ts @@ -0,0 +1,19 @@ +import path from "path" + +// Strips a known prefix from an already-relative path. Callers should pass the +// path relative to the directory they scanned (e.g. `path.relative(dir, item)`) +// so the prefix match is anchored. Matching anywhere in an absolute path used +// to mis-key agents whose home/parent segments coincidentally contained one of +// the prefix names (see #25713). +function stripPrefix(relativePath: string, prefixes: string[]) { + const normalized = relativePath.replaceAll("\\", "/") + for (const prefix of prefixes) { + if (normalized.startsWith(prefix)) return normalized.slice(prefix.length) + } +} + +export function configEntryNameFromPath(relativePath: string, prefixes: string[]) { + const candidate = stripPrefix(relativePath, prefixes) ?? path.basename(relativePath) + const ext = path.extname(candidate) + return ext.length ? candidate.slice(0, -ext.length) : candidate +} diff --git a/packages/opencode/src/config/managed.ts b/packages/opencode/src/config/managed.ts new file mode 100644 index 0000000000000000000000000000000000000000..877d30aaa55cac2d1d7df17b473a85587c044f55 --- /dev/null +++ b/packages/opencode/src/config/managed.ts @@ -0,0 +1,69 @@ +export * as ConfigManaged from "./managed" + +import { existsSync } from "fs" +import os from "os" +import path from "path" +import { Process } from "@/util/process" + +const MANAGED_PLIST_DOMAIN = "ai.opencode.managed" + +// Keys injected by macOS/MDM into the managed plist that are not OpenCode config +const PLIST_META = new Set([ + "PayloadDisplayName", + "PayloadIdentifier", + "PayloadType", + "PayloadUUID", + "PayloadVersion", + "_manualProfile", +]) + +function systemManagedConfigDir(): string { + switch (process.platform) { + case "darwin": + return "/Library/Application Support/opencode" + case "win32": + return path.join(process.env.ProgramData || "C:\\ProgramData", "opencode") + default: + return "/etc/opencode" + } +} + +export function managedConfigDir() { + return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir() +} + +export function parseManagedPlist(json: string): string { + const raw = JSON.parse(json) + for (const key of Object.keys(raw)) { + if (PLIST_META.has(key)) delete raw[key] + } + return JSON.stringify(raw) +} + +export async function readManagedPreferences() { + if (process.platform !== "darwin") return + + const user = (() => { + try { + return os.userInfo().username || "user" + } catch { + return "user" + } + })() + const paths = [ + path.join("/Library/Managed Preferences", user, `${MANAGED_PLIST_DOMAIN}.plist`), + path.join("/Library/Managed Preferences", `${MANAGED_PLIST_DOMAIN}.plist`), + ] + + for (const plist of paths) { + if (!existsSync(plist)) continue + const result = await Process.run(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true }) + if (result.code !== 0) continue + return { + source: `mobileconfig:${plist}`, + text: parseManagedPlist(result.stdout.toString()), + } + } + + return +} diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts new file mode 100644 index 0000000000000000000000000000000000000000..297db9cc823cf76676614984da41d73bae23711c --- /dev/null +++ b/packages/opencode/src/config/markdown.ts @@ -0,0 +1,36 @@ +import { Filesystem } from "@/util/filesystem" +import { FrontmatterError } from "@opencode-ai/core/v1/config/error" +import { ConfigMarkdown as ConfigMarkdownCore } from "@opencode-ai/core/config/markdown" + +export const FILE_REGEX = /(? { + const beforeOffset = text.substring(0, e.offset).split("\n") + const line = beforeOffset.length + const column = beforeOffset[beforeOffset.length - 1].length + 1 + const problemLine = lines[line - 1] + + const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}` + if (!problemLine) return error + + return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^` + }) + .join("\n") + throw new JsonError({ + path: filepath, + message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues}\n--- End ---`, + }) + } + + return data +} + +export function schema>( + schema: S, + data: unknown, + source: string, +): DeepMutable { + const decoded = EffectSchema.decodeUnknownExit(schema)(data, { + errors: "all", + onExcessProperty: "ignore", + propertyOrder: "original", + }) + if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable + const error = Cause.squash(decoded.cause) + + throw new InvalidError( + { + path: source, + issues: EffectSchema.isSchemaError(error) + ? SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues.map((issue) => ({ + ...issue, + message: issue.message, + path: issue.path?.map(String) ?? [], + })) + : [{ message: String(error), path: [] }], + }, + { cause: error }, + ) +} diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts new file mode 100644 index 0000000000000000000000000000000000000000..11d90f1292ab2b1e33fe5798327aaaaa0d8a397f --- /dev/null +++ b/packages/opencode/src/config/paths.ts @@ -0,0 +1,45 @@ +export * as ConfigPaths from "./paths" + +import path from "path" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { unique } from "remeda" +import * as Effect from "effect/Effect" +import { FSUtil } from "@opencode-ai/core/fs-util" + +export const files = Effect.fn("ConfigPaths.projectFiles")(function* ( + name: string, + directory: string, + worktree?: string, +) { + const afs = yield* FSUtil.Service + return (yield* afs.up({ + targets: [`${name}.jsonc`, `${name}.json`], + start: directory, + stop: worktree, + })).toReversed() +}) + +export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) { + const afs = yield* FSUtil.Service + return unique([ + Global.Path.config, + ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? yield* afs.up({ + targets: [".opencode"], + start: directory, + stop: worktree, + }) + : []), + ...(yield* afs.up({ + targets: [".opencode"], + start: Global.Path.home, + stop: Global.Path.home, + })), + ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []), + ]) +}) + +export function fileInDirectory(dir: string, name: string) { + return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] +} diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts new file mode 100644 index 0000000000000000000000000000000000000000..60bba4d636343448aef14ec0f174e769b95b248f --- /dev/null +++ b/packages/opencode/src/config/plugin.ts @@ -0,0 +1,79 @@ +import { Glob } from "@opencode-ai/core/util/glob" +import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" +import { pathToFileURL } from "url" +import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" +import path from "path" + +export type Scope = "global" | "local" + +// Origin keeps the original config provenance attached to a spec. +// After multiple config files are merged, callers still need to know which file declared the plugin +// and whether it should behave like a global or project-local plugin. +export type Origin = { + spec: ConfigPluginV1.Spec + source: string + scope: Scope +} + +export async function load(dir: string) { + const plugins: ConfigPluginV1.Spec[] = [] + + for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", { + cwd: dir, + absolute: true, + dot: true, + symlink: true, + })) { + plugins.push(pathToFileURL(item).href) + } + return plugins +} + +export function pluginSpecifier(plugin: ConfigPluginV1.Spec): string { + return Array.isArray(plugin) ? plugin[0] : plugin +} + +export function pluginOptions(plugin: ConfigPluginV1.Spec): ConfigPluginV1.Options | undefined { + return Array.isArray(plugin) ? plugin[1] : undefined +} + +// Path-like specs are resolved relative to the config file that declared them so merges later on do not +// accidentally reinterpret `./plugin.ts` relative to some other directory. +export async function resolvePluginSpec( + plugin: ConfigPluginV1.Spec, + configFilepath: string, +): Promise { + const spec = pluginSpecifier(plugin) + if (!isPathPluginSpec(spec)) return plugin + + const base = path.dirname(configFilepath) + const file = (() => { + if (spec.startsWith("file://")) return spec + if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href + return pathToFileURL(path.resolve(base, spec)).href + })() + + const resolved = await resolvePathPluginTarget(file).catch(() => file) + + if (Array.isArray(plugin)) return [resolved, plugin[1]] + return resolved +} + +// Dedupe on the load identity (package name for npm specs, exact file URL for local specs), but keep the +// full Origin so downstream code still knows which config file won and where follow-up writes should go. +export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] { + const seen = new Set() + const list: Origin[] = [] + + for (const plugin of plugins.toReversed()) { + const spec = pluginSpecifier(plugin.spec) + const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg + if (seen.has(name)) continue + seen.add(name) + list.push(plugin) + } + + return list.toReversed() +} + +export * as ConfigPlugin from "./plugin" diff --git a/packages/opencode/src/config/tui-cwd.ts b/packages/opencode/src/config/tui-cwd.ts new file mode 100644 index 0000000000000000000000000000000000000000..22f342d8d33cc394208b81cc7af9523e04a27129 --- /dev/null +++ b/packages/opencode/src/config/tui-cwd.ts @@ -0,0 +1,5 @@ +import { Context } from "effect" + +export const CurrentWorkingDirectory = Context.Reference("CurrentWorkingDirectory", { + defaultValue: () => process.cwd(), +}) diff --git a/packages/opencode/src/config/tui-host-attention.ts b/packages/opencode/src/config/tui-host-attention.ts new file mode 100644 index 0000000000000000000000000000000000000000..d74ce888b9e47245ecb5ada5ce368a0bc20f4750 --- /dev/null +++ b/packages/opencode/src/config/tui-host-attention.ts @@ -0,0 +1,21 @@ +import { TuiConfig } from "@opencode-ai/tui/config" +import { isRecord } from "@opencode-ai/tui/util/record" +import { Filesystem } from "@/util/filesystem" +import { Schema } from "effect" + +export function resolveHostAttentionSoundPaths( + root: string, + sounds: unknown, + options?: { trim?: boolean }, +): TuiConfig.AttentionSoundPaths { + if (!isRecord(sounds)) return {} + return Object.fromEntries( + Object.entries(sounds).flatMap(([name, file]) => { + if (!Schema.is(TuiConfig.AttentionSoundName)(name)) return [] + if (typeof file !== "string") return [] + const value = options?.trim ? file.trim() : file + if (!value) return [] + return [[name, Filesystem.resolveFilePath(root, value)]] + }), + ) +} diff --git a/packages/opencode/src/config/tui-migrate.ts b/packages/opencode/src/config/tui-migrate.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ca254311e3bb8101a15feef9d429ae40dc2a4e7 --- /dev/null +++ b/packages/opencode/src/config/tui-migrate.ts @@ -0,0 +1,132 @@ +import path from "path" +import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser" +import { unique } from "remeda" +import { Option, Schema } from "effect" +import { TuiConfig } from "@opencode-ai/tui/config" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Filesystem } from "@/util/filesystem" +import * as ConfigPaths from "@/config/paths" + +const TUI_SCHEMA_URL = "https://opencode.ai/tui.json" + +const decodeTheme = Schema.decodeUnknownOption(Schema.String) +const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown)) +const decodeScrollSpeed = Schema.decodeUnknownOption(TuiConfig.ScrollSpeed) +const decodeScrollAcceleration = Schema.decodeUnknownOption(TuiConfig.ScrollAcceleration) +const decodeDiffStyle = Schema.decodeUnknownOption(TuiConfig.DiffStyle) + +interface MigrateInput { + cwd: string + directories: string[] +} + +/** + * Migrates tui-specific keys (theme, keybinds, tui) from opencode.json files + * into dedicated tui.json files. Migration is performed per-directory and + * skips only locations where a tui.json already exists. + */ +export async function migrateTuiConfig(input: MigrateInput) { + const opencode = await opencodeFiles(input) + for (const file of opencode) { + const source = await Filesystem.readText(file).catch(() => undefined) + if (!source) continue + const errors: JsoncParseError[] = [] + const data = parseJsonc(source, errors, { allowTrailingComma: true }) + if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue + + const theme = decodeTheme("theme" in data ? data.theme : undefined) + const keybinds = decodeRecord("keybinds" in data ? data.keybinds : undefined) + const legacyTui = decodeRecord("tui" in data ? data.tui : undefined) + const extracted = { + theme: Option.getOrUndefined(theme), + keybinds: Option.getOrUndefined(keybinds), + tui: Option.getOrUndefined(legacyTui), + } + const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined + if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue + + const target = path.join(path.dirname(file), "tui.json") + const targetExists = await Filesystem.exists(target) + if (targetExists) continue + + const payload: Record = { + $schema: TUI_SCHEMA_URL, + } + if (extracted.theme !== undefined) payload.theme = extracted.theme + if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds + if (tui) Object.assign(payload, tui) + + const wrote = await Filesystem.write(target, JSON.stringify(payload, null, 2)) + .then(() => true) + .catch(() => false) + if (!wrote) continue + + const stripped = await backupAndStripLegacy(file, source) + if (!stripped) continue + } +} + +function normalizeTui(data: Record): + | { + scroll_speed: number | undefined + scroll_acceleration: { enabled: boolean } | undefined + diff_style: "auto" | "stacked" | undefined + } + | undefined { + const parsed = { + scroll_speed: Option.getOrUndefined(decodeScrollSpeed(data.scroll_speed)), + scroll_acceleration: Option.getOrUndefined(decodeScrollAcceleration(data.scroll_acceleration)), + diff_style: Option.getOrUndefined(decodeDiffStyle(data.diff_style)), + } + return parsed.scroll_speed === undefined && + parsed.diff_style === undefined && + parsed.scroll_acceleration === undefined + ? undefined + : parsed +} + +async function backupAndStripLegacy(file: string, source: string) { + const backup = file + ".tui-migration.bak" + const hasBackup = await Filesystem.exists(backup) + const backed = hasBackup + ? true + : await Filesystem.write(backup, source) + .then(() => true) + .catch(() => false) + if (!backed) return false + + const text = ["theme", "keybinds", "tui"].reduce((acc, key) => { + const edits = modify(acc, [key], undefined, { + formattingOptions: { + insertSpaces: true, + tabSize: 2, + }, + }) + if (!edits.length) return acc + return applyEdits(acc, edits) + }, source) + + return Filesystem.write(file, text) + .then(() => true) + .catch(() => false) +} + +async function opencodeFiles(input: { directories: string[]; cwd: string }) { + const files = [ + ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode"), + ...(await Filesystem.findUp(["opencode.json", "opencode.jsonc"], input.cwd, undefined, { rootFirst: true })), + ] + for (const dir of unique(input.directories)) { + files.push(...ConfigPaths.fileInDirectory(dir, "opencode")) + } + if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG) + + const existing = await Promise.all( + unique(files).map(async (file) => { + const ok = await Filesystem.exists(file) + return ok ? file : undefined + }), + ) + return existing.filter((file): file is string => !!file) +} diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f503751f379447d4b73ed901f35f18166fda95a --- /dev/null +++ b/packages/opencode/src/config/tui.ts @@ -0,0 +1,276 @@ +export * as TuiConfig from "./tui" + +import path from "path" +import { mergeDeep, unique } from "remeda" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Cause, Context, Effect, Fiber, Layer } from "effect" +import { ConfigParse } from "@/config/parse" +import * as ConfigPaths from "@/config/paths" +import { migrateTuiConfig } from "./tui-migrate" +import { resolveHostAttentionSoundPaths } from "./tui-host-attention" +import { Flag } from "@opencode-ai/core/flag/flag" +import { isRecord } from "@opencode-ai/tui/util/record" +import { Global } from "@opencode-ai/core/global" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { CurrentWorkingDirectory } from "./tui-cwd" +import { ConfigPlugin } from "@/config/plugin" +import { TuiKeybind } from "@opencode-ai/tui/config/keybind" +import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" +import { makeRuntime } from "@opencode-ai/core/effect/runtime" +import { Filesystem } from "@/util/filesystem" +import { ConfigVariable } from "@/config/variable" +import { Npm } from "@opencode-ai/core/npm" +import { FormatError, FormatUnknownError } from "@/cli/error" +import { TuiConfig } from "@opencode-ai/tui/config" + +export const Info = TuiConfig.Info +export type Info = TuiConfig.Info + +type Acc = { + result: Info + plugin_origins: ConfigPlugin.Origin[] +} + +export type Resolved = TuiConfig.Resolved + +export type HostMetadata = { + plugin_origins?: ConfigPlugin.Origin[] +} + +export interface Interface { + readonly get: () => Effect.Effect + readonly pluginOrigins: () => Effect.Effect + readonly waitForDependencies: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/TuiConfig") {} + +function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope { + if (Filesystem.contains(ctx.directory, file)) return "local" + // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local" + return "global" +} + +function normalize(raw: Record) { + const data = { ...raw } + if (!("tui" in data)) return data + if (!isRecord(data.tui)) { + delete data.tui + return data + } + + const tui = data.tui + delete data.tui + return { + ...tui, + ...data, + } +} + +function dropUnknownKeybinds(input: Record) { + if (!isRecord(input.keybinds)) return input + + const invalid = TuiKeybind.unknownKeys(input.keybinds) + if (!invalid.length) return input + + return { + ...input, + keybinds: Object.fromEntries(Object.entries(input.keybinds).filter(([key]) => !invalid.includes(key))), + } +} + +const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { + const afs = yield* FSUtil.Service + let appliedOrder = 0 + + const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect => + Effect.gen(function* () { + const plugins = config.plugin + if (!plugins) return config + return { + ...config, + plugin: yield* Effect.forEach(plugins, (plugin) => + Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugin as ConfigPlugin.Origin["spec"], configFilepath)), + ), + } + }) + + const load = (text: string, configFilepath: string): Effect.Effect => + Effect.gen(function* () { + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), + ) + const data = ConfigParse.jsonc(expanded, configFilepath) + if (!isRecord(data)) return {} as Info + // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json + // (mirroring the old opencode.json shape) still get their settings applied. + const normalized = dropUnknownKeybinds(normalize(data)) + const parsed = ConfigParse.schema(Info, normalized, configFilepath) + const validated = parsed.attention?.sounds + ? { + ...parsed, + attention: { + ...parsed.attention, + sounds: resolveHostAttentionSoundPaths(path.dirname(configFilepath), parsed.attention.sounds), + }, + } + : parsed + return yield* resolvePlugins(validated, configFilepath) + }).pipe( + // catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation + // can sync-throw — those become defects, which orElseSucceed wouldn't catch. + Effect.catchCause((cause) => + Effect.logWarning("skipping invalid tui config", { + path: configFilepath, + reason: FormatError(Cause.squash(cause)) ?? FormatUnknownError(Cause.squash(cause)), + }).pipe(Effect.as({} as Info)), + ), + ) + + const loadFile = (filepath: string): Effect.Effect => + Effect.gen(function* () { + // Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip. + // Matches how parse/schema/plugin failures in load() are handled — every + // broken-config path degrades gracefully rather than crashing TUI startup. + const text = yield* afs.readFileStringSafe(filepath).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to read tui config", { + path: filepath, + reason: FormatError(Cause.squash(cause)) ?? FormatUnknownError(Cause.squash(cause)), + }).pipe(Effect.as(undefined)), + ), + ) + if (!text) return {} as Info + yield* Effect.logInfo("loading tui config", { path: filepath }) + return yield* load(text, filepath) + }) + + const mergeFile = (acc: Acc, file: string) => + Effect.gen(function* () { + const data = yield* loadFile(file) + if (Object.keys(data).length) { + appliedOrder += 1 + yield* Effect.logInfo("applying tui config", { path: file, order: appliedOrder }) + } + acc.result = mergeDeep(acc.result, data) + if (!data.plugin?.length) return + + const scope = pluginScope(file, ctx) + const plugins = ConfigPlugin.deduplicatePluginOrigins([ + ...acc.plugin_origins, + ...data.plugin.map((spec) => ({ spec: spec as ConfigPlugin.Origin["spec"], scope, source: file })), + ]) + acc.result = { + ...acc.result, + plugin: plugins.map((item) => item.spec), + } + acc.plugin_origins = plugins + }) + + // Every config dir we may read from: global config dir, any `.opencode` + // folders between cwd and home, and OPENCODE_CONFIG_DIR. + const directories = yield* ConfigPaths.directories(ctx.directory) + yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory })) + + const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory) + + const acc: Acc = { + result: {}, + plugin_origins: [], + } + + // 1. Global tui config (lowest precedence). + for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { + yield* mergeFile(acc, file) + } + + // 2. Explicit OPENCODE_TUI_CONFIG override, if set. + if (Flag.OPENCODE_TUI_CONFIG) { + const configFile = Flag.OPENCODE_TUI_CONFIG + yield* mergeFile(acc, configFile) + yield* Effect.logDebug("loaded custom tui config", { path: configFile }) + } + + // 3. Project tui files, applied root-first so the closest file wins. + for (const file of projectFiles) { + yield* mergeFile(acc, file) + } + + // 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while + // walking up the tree. Also returned below so callers can install plugin + // dependencies from each location. + const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) + + for (const dir of dirs) { + if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue + for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { + yield* mergeFile(acc, file) + } + } + + const result = TuiConfig.resolve( + { + ...acc.result, + }, + { + terminalSuspend: process.platform !== "win32", + }, + ) + + return { + config: result, + pluginOrigins: acc.plugin_origins, + dirs: result.plugin?.length ? dirs : [], + } +}) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const directory = yield* CurrentWorkingDirectory + const npm = yield* Npm.Service + const data = yield* loadState({ directory }) + const deps = yield* Effect.forEach( + data.dirs, + (dir) => + npm + .install(dir, { + add: [ + { + name: "@opencode-ai/plugin", + version: InstallationLocal ? undefined : InstallationVersion, + }, + ], + }) + .pipe(Effect.forkScoped), + { + concurrency: "unbounded", + }, + ) + + const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config)) + const pluginOrigins = Effect.fn("TuiConfig.pluginOrigins")(() => Effect.succeed(data.pluginOrigins)) + + const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => + Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid), + ) + return Service.of({ get, pluginOrigins, waitForDependencies }) + }).pipe(Effect.withSpan("TuiConfig.layer")), +) + +export const node = LayerNode.make({ service: Service, layer, deps: [Npm.node, FSUtil.node] }) + +const { runPromise } = makeRuntime(Service, AppNodeBuilder.build(node)) + +export async function waitForDependencies() { + await runPromise((svc) => svc.waitForDependencies()) +} + +export async function get() { + return runPromise((svc) => svc.get()) +} + +export async function pluginOrigins() { + return runPromise((svc) => svc.pluginOrigins()) +} diff --git a/packages/opencode/src/config/v2-compat.ts b/packages/opencode/src/config/v2-compat.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e4e0bf54089af28bf96db7e8825c12c92bec723 --- /dev/null +++ b/packages/opencode/src/config/v2-compat.ts @@ -0,0 +1,449 @@ +export * as ConfigV2Compat from "./v2-compat" + +import { isDeepStrictEqual } from "node:util" +import { Option, Schema } from "effect" +import { NonNegativeInt, PositiveInt } from "@opencode-ai/core/schema" +import { ConfigAttachmentV1 } from "@opencode-ai/core/v1/config/attachment" +import { ConfigLSPV1 } from "@opencode-ai/core/v1/config/lsp" +import { InvalidError } from "@opencode-ai/core/v1/config/error" + +export interface Diagnostic { + readonly kind: "invalid" | "unsupported" | "conflict" + readonly path: readonly string[] + readonly message: string +} + +export interface Result { + readonly value: unknown + readonly diagnostics: readonly Diagnostic[] +} + +const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const +const Record = Schema.Record(Schema.String, Schema.Unknown) +const Timeout = Schema.Struct({ + startup: Schema.optional(PositiveInt), + catalog: Schema.optional(PositiveInt), + execution: Schema.optional(PositiveInt), +}) +const OAuth = Schema.Struct({ + client_id: Schema.optional(Schema.String), + client_secret: Schema.optional(Schema.String), + scope: Schema.optional(Schema.String), + callback_port: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), + redirect_uri: Schema.optional(Schema.String), +}) +const Server = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("local"), + command: Schema.Array(Schema.String), + cwd: Schema.optional(Schema.String), + environment: Schema.optional(Schema.Record(Schema.String, Schema.String)), + disabled: Schema.optional(Schema.Boolean), + codemode: Schema.optional(Schema.Boolean), + timeout: Schema.optional(Timeout), + }), + Schema.Struct({ + type: Schema.Literal("remote"), + url: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])), + disabled: Schema.optional(Schema.Boolean), + codemode: Schema.optional(Schema.Boolean), + timeout: Schema.optional(Timeout), + }), +]) +const Selection = Schema.Union([ + Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/)), + Schema.Struct({ + providerID: Schema.String.check(Schema.isPattern(/^[^/#]+$/)), + model: Schema.String.check(Schema.isPattern(/^[^#]+$/)), + variant: Schema.optional(Schema.String.check(Schema.isPattern(/^[^#]+$/))), + }), +]) +const Agent = Schema.Struct({ + model: Schema.optional(Selection), + request: Schema.optional( + Schema.Struct({ + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + body: Schema.optional(Schema.Record(Schema.String, Schema.Json)), + }), + ), + system: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), + mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])), + hidden: Schema.optional(Schema.Boolean), + color: Schema.optional(Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))), + steps: Schema.optional(PositiveInt), + disabled: Schema.optional(Schema.Boolean), +}) +const Command = Schema.Struct({ + template: Schema.String, + description: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Selection), + subtask: Schema.optional(Schema.Boolean), +}) + +const decodeRecord = Schema.decodeUnknownOption(Record, decodeOptions) +const decodeLspEntry = Schema.decodeUnknownOption(ConfigLSPV1.Entry, decodeOptions) +const builtinServers = new Set(ConfigLSPV1.builtinServerIds) + +export function lower(input: unknown, source = "configuration"): Result { + const parsed = decodeRecord(input) + if (Option.isNone(parsed)) return { value: input, diagnostics: [] } + + const permissions = [ + ...(Object.hasOwn(parsed.value, "permissions") ? [["permissions"]] : []), + ...["agents", "agent", "mode"].flatMap((key) => { + const agents = decodeRecord(parsed.value[key]) + if (Option.isNone(agents)) return [] + return Object.entries(agents.value).flatMap(([name, value]) => { + const agent = decodeRecord(value) + return Option.isSome(agent) && Object.hasOwn(agent.value, "permissions") ? [[key, name, "permissions"]] : [] + }) + }), + ] + if (permissions.length) + throw new InvalidError({ + path: source, + issues: permissions.map((path) => ({ + path, + message: 'V2 permissions are not supported by OpenCode V1. Use V1 "permission" rules or run opencode2.', + })), + }) + + const result: Record = { ...parsed.value } + const diagnostics: Diagnostic[] = [] + for (const key of ["plugins", "providers", "websearch", "warming"]) + if (Object.hasOwn(parsed.value, key)) unsupported([key], diagnostics) + + normalizeSettings(parsed.value, result, diagnostics) + normalizeModel(parsed.value, result, diagnostics) + normalizeSkills(parsed.value, result, diagnostics) + normalizeCompaction(parsed.value, result, diagnostics) + normalizeExperimental(parsed.value, result, diagnostics) + + normalizeAgents(parsed.value, result, diagnostics) + normalizeCommands(parsed.value, result, diagnostics) + normalizeMcp(parsed.value, result, diagnostics) + normalizeLsp(parsed.value, result, diagnostics) + + return { value: result, diagnostics } +} + +function normalizeSettings(input: Record, result: Record, diagnostics: Diagnostic[]) { + if (Object.hasOwn(input, "snapshots")) { + const value = decodeValue(Schema.Boolean, input.snapshots, ["snapshots"], diagnostics) + if (value !== undefined) preferLegacy(result, "snapshot", value, ["snapshots"], diagnostics) + } + if (Object.hasOwn(input, "media")) { + const value = decodeValue(ConfigAttachmentV1.Info, input.media, ["media"], diagnostics) + if (value !== undefined) preferLegacy(result, "attachment", value, ["media"], diagnostics) + } +} + +function normalizeModel(input: Record, result: Record, diagnostics: Diagnostic[]) { + if (!Object.hasOwn(input, "model")) return + const selection = Schema.decodeUnknownOption(Selection, decodeOptions)(input.model) + if (Option.isNone(selection)) return + const value = lowerSelection(selection.value) + result.model = value.model + if (value.variant !== undefined) unsupported(["model", "variant"], diagnostics) +} + +function normalizeSkills(input: Record, result: Record, diagnostics: Diagnostic[]) { + if (!Array.isArray(input.skills)) return + const skills = decodeValue(Schema.Array(Schema.String), input.skills, ["skills"], diagnostics) + if (skills === undefined) return + result.skills = { + paths: skills.filter((value) => !/^https?:\/\//i.test(value)), + urls: skills.filter((value) => /^https?:\/\//i.test(value)), + } +} + +function normalizeCompaction( + input: Record, + result: Record, + diagnostics: Diagnostic[], +) { + const compaction = decodeRecord(input.compaction) + if (Option.isNone(compaction)) return + const value = { ...compaction.value } + if (Object.hasOwn(value, "keep")) { + const keep = decodeValue(Record, value.keep, ["compaction", "keep"], diagnostics) + if (keep !== undefined && Object.hasOwn(keep, "tokens")) { + const tokens = decodeValue(NonNegativeInt, keep.tokens, ["compaction", "keep", "tokens"], diagnostics) + if (tokens !== undefined) + preferLegacy(value, "preserve_recent_tokens", tokens, ["compaction", "keep", "tokens"], diagnostics) + } + } + if (Object.hasOwn(value, "buffer")) { + const buffer = decodeValue(NonNegativeInt, value.buffer, ["compaction", "buffer"], diagnostics) + if (buffer !== undefined) preferLegacy(value, "reserved", buffer, ["compaction", "buffer"], diagnostics) + } + result.compaction = value +} + +function normalizeExperimental( + input: Record, + result: Record, + diagnostics: Diagnostic[], +) { + const experimental = decodeRecord(input.experimental) + if (Option.isNone(experimental)) return + if (Object.hasOwn(experimental.value, "portable_shell_scanner")) + unsupported(["experimental", "portable_shell_scanner"], diagnostics) + if (!Object.hasOwn(experimental.value, "subagent_depth")) return + const depth = decodeValue( + NonNegativeInt, + experimental.value.subagent_depth, + ["experimental", "subagent_depth"], + diagnostics, + ) + if (depth !== undefined) + preferLegacy(result, "subagent_depth", depth, ["experimental", "subagent_depth"], diagnostics) +} + +function normalizeAgents(input: Record, result: Record, diagnostics: Diagnostic[]) { + if (!Object.hasOwn(input, "agents")) return + const agents = decodeValue(Record, input.agents, ["agents"], diagnostics) + if (agents === undefined) return + const legacy = decodeRecord(result.agent) + const merged: Record = Option.isSome(legacy) ? { ...legacy.value } : {} + for (const [name, value] of Object.entries(agents)) { + const path = ["agents", name] + if (Object.hasOwn(merged, name)) { + if (!isDeepStrictEqual(merged[name], value)) conflict(path, diagnostics) + continue + } + const parsed = decodeValue(Agent, value, path, diagnostics) + if (parsed === undefined) continue + if (parsed.request?.headers !== undefined) unsupported([...path, "request", "headers"], diagnostics) + setOwn(merged, name, lowerAgent(parsed)) + } + if (Object.hasOwn(result, "agent") && Option.isNone(legacy)) return + if (Object.keys(merged).length > 0 || Option.isSome(legacy)) result.agent = merged +} + +function normalizeCommands(input: Record, result: Record, diagnostics: Diagnostic[]) { + if (!Object.hasOwn(input, "commands")) return + const commands = decodeValue(Record, input.commands, ["commands"], diagnostics) + if (commands === undefined) return + const legacy = decodeRecord(result.command) + if (Object.hasOwn(result, "command") && Option.isNone(legacy)) return + const merged: Record = Option.isSome(legacy) ? { ...legacy.value } : {} + for (const [name, value] of Object.entries(commands)) { + const path = ["commands", name] + const parsed = decodeValue(Command, value, path, diagnostics) + if (parsed === undefined) continue + preferLegacy(merged, name, lowerCommand(parsed), path, diagnostics) + } + if (Object.keys(merged).length > 0 || Option.isSome(legacy)) result.command = merged +} + +function normalizeMcp(input: Record, result: Record, diagnostics: Diagnostic[]) { + const mcp = decodeRecord(input.mcp) + if (Option.isNone(mcp)) return + const servers: Record = {} + const nested = decodeRecord(mcp.value.servers) + const envelope = Option.isSome(nested) && !isDirectServer(nested.value) + const timeoutRecord = decodeRecord(mcp.value.timeout) + const timeout = Schema.decodeUnknownOption(Timeout, decodeOptions)(mcp.value.timeout) + const globalTimeout = + Option.isSome(timeout) && + Option.isSome(timeoutRecord) && + !isDirectServer(timeoutRecord.value) && + (Object.keys(timeoutRecord.value).length === 0 || + ["startup", "catalog", "execution"].some((key) => Object.hasOwn(timeoutRecord.value, key))) + + for (const [name, value] of Object.entries(mcp.value)) { + if (name === "servers" && envelope) continue + if (name === "timeout" && globalTimeout) continue + const path = ["mcp", name] + const record = decodeRecord(value) + const oauth = Option.isSome(record) ? decodeRecord(record.value.oauth) : Option.none() + const native = + Option.isSome(record) && + (Object.hasOwn(record.value, "disabled") || + Object.hasOwn(record.value, "codemode") || + typeof record.value.timeout === "object" || + (Option.isSome(oauth) && + ["client_id", "client_secret", "callback_port", "redirect_uri"].some((key) => + Object.hasOwn(oauth.value, key), + ))) + // Keep invalid flat entries for the final V1 decoder rather than sanitizing them. + setOwn(servers, name, native ? (normalizeServer(value, path, diagnostics) ?? value) : value) + } + + if (envelope && Option.isSome(nested)) { + for (const [name, value] of Object.entries(nested.value)) { + const path = ["mcp", "servers", name] + if (Object.hasOwn(servers, name)) { + if (!isDeepStrictEqual(servers[name], value)) conflict(path, diagnostics) + continue + } + const record = decodeRecord(value) + if ( + Option.isSome(record) && + typeof record.value.enabled === "boolean" && + !Object.hasOwn(record.value, "disabled") + ) { + setOwn(servers, name, value) + continue + } + const server = normalizeServer(value, path, diagnostics) + if (server !== undefined) setOwn(servers, name, server) + } + } + result.mcp = servers + + if (!globalTimeout || Option.isNone(timeout)) return + const value = lowerTimeout(timeout.value) + if (value === undefined) { + if (Object.keys(timeout.value).length) unsupported(["mcp", "timeout"], diagnostics) + return + } + const existing = decodeRecord(result.experimental) + if (Object.hasOwn(result, "experimental") && Option.isNone(existing)) return + const experimental = Option.isSome(existing) ? { ...existing.value } : {} + preferLegacy(experimental, "mcp_timeout", value, ["mcp", "timeout"], diagnostics) + result.experimental = experimental +} + +function isDirectServer(value: Record) { + // Object-valued entries can be servers literally named "type" or "enabled". + return ["type", "enabled"].some( + (key) => + Object.hasOwn(value, key) && (value[key] === null || typeof value[key] !== "object" || Array.isArray(value[key])), + ) +} + +function normalizeServer(input: unknown, path: string[], diagnostics: Diagnostic[]) { + const server = decodeValue(Server, input, path, diagnostics) + if (server === undefined) return + if (server.codemode !== undefined) unsupported([...path, "codemode"], diagnostics) + if (server.timeout && lowerTimeout(server.timeout) === undefined && Object.keys(server.timeout).length) + unsupported([...path, "timeout"], diagnostics) + const raw = decodeRecord(input) + if (Option.isNone(raw) || !Object.hasOwn(raw.value, "enabled")) return lowerServer(server) + if (server.disabled !== undefined && raw.value.enabled === server.disabled) + conflict([...path, "disabled"], diagnostics) + return { ...lowerServer(server), enabled: raw.value.enabled } +} + +function normalizeLsp(input: Record, result: Record, diagnostics: Diagnostic[]) { + const lsp = decodeRecord(input.lsp) + if (Option.isNone(lsp)) return + result.lsp = Object.fromEntries( + Object.entries(lsp.value).filter(([name, value]) => { + if (builtinServers.has(name)) return true + const entry = decodeLspEntry(value) + if (Option.isNone(entry)) return true + if (entry.value.disabled === true) return true + if ("extensions" in entry.value && entry.value.extensions !== undefined) return true + unsupported(["lsp", name], diagnostics) + return false + }), + ) +} + +function lowerSelection(input: Schema.Schema.Type) { + if (typeof input !== "string") { + return { + model: `${input.providerID}/${input.model}`, + ...(input.variant !== undefined ? { variant: input.variant } : {}), + } + } + const index = input.indexOf("#") + if (index === -1) return { model: input } + return { model: input.slice(0, index), variant: input.slice(index + 1) } +} + +function lowerTimeout(input: Schema.Schema.Type) { + if (input.startup !== undefined) return undefined + if (input.catalog === undefined || input.execution === undefined) return undefined + if (input.catalog !== input.execution) return undefined + return input.catalog +} + +function lowerServer(input: Schema.Schema.Type) { + const result: Record = { + ...input, + enabled: input.disabled !== true, + } + delete result.disabled + delete result.codemode + delete result.timeout + + if (input.timeout) { + const timeout = lowerTimeout(input.timeout) + if (timeout !== undefined) result.timeout = timeout + } + + if (input.type === "remote" && input.oauth && typeof input.oauth === "object") { + const oauth: Record = {} + if (input.oauth.client_id !== undefined) oauth.clientId = input.oauth.client_id + if (input.oauth.client_secret !== undefined) oauth.clientSecret = input.oauth.client_secret + if (input.oauth.scope !== undefined) oauth.scope = input.oauth.scope + if (input.oauth.callback_port !== undefined) oauth.callbackPort = input.oauth.callback_port + if (input.oauth.redirect_uri !== undefined) oauth.redirectUri = input.oauth.redirect_uri + result.oauth = oauth + } + + return result +} + +function lowerAgent(input: Schema.Schema.Type) { + const result: Record = {} + for (const key of ["description", "mode", "hidden", "color", "steps"] as const) { + if (input[key] !== undefined) result[key] = input[key] + } + if (input.system !== undefined) result.prompt = input.system + if (input.disabled !== undefined) result.disable = input.disabled + if (input.model !== undefined) Object.assign(result, lowerSelection(input.model)) + if (input.request?.body !== undefined) result.options = input.request.body + + return result +} + +function lowerCommand(input: Schema.Schema.Type) { + return { ...input, ...(input.model !== undefined ? lowerSelection(input.model) : {}) } +} + +function decodeValue>( + schema: S, + value: unknown, + path: string[], + diagnostics: Diagnostic[], +) { + const decoded = Schema.decodeUnknownOption(schema, decodeOptions)(value) + if (Option.isSome(decoded)) return decoded.value + diagnostics.push({ kind: "invalid", path, message: "Native setting could not be lowered because it is malformed" }) + return undefined +} + +function preferLegacy( + target: Record, + key: string, + value: unknown, + path: string[], + diagnostics: Diagnostic[], +) { + if (Object.hasOwn(target, key)) { + if (!isDeepStrictEqual(target[key], value)) conflict(path, diagnostics) + return + } + setOwn(target, key, value) +} + +function setOwn(target: Record, key: string, value: unknown) { + Object.defineProperty(target, key, { value, enumerable: true, configurable: true, writable: true }) +} + +function unsupported(path: string[], diagnostics: Diagnostic[]) { + diagnostics.push({ kind: "unsupported", path, message: "Omitted native setting that cannot be represented in V1" }) +} + +function conflict(path: string[], diagnostics: Diagnostic[]) { + diagnostics.push({ kind: "conflict", path, message: "Retained legacy value over native value" }) +} diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts new file mode 100644 index 0000000000000000000000000000000000000000..52c449538fa19b7986c12906429ab31bdd783daf --- /dev/null +++ b/packages/opencode/src/config/variable.ts @@ -0,0 +1,91 @@ +export * as ConfigVariable from "./variable" + +import path from "path" +import os from "os" +import { Filesystem } from "@/util/filesystem" +import { InvalidError } from "@opencode-ai/core/v1/config/error" + +type ParseSource = + | { + type: "path" + path: string + } + | { + type: "virtual" + source: string + dir: string + } + +type SubstituteInput = ParseSource & { + text: string + missing?: "error" | "empty" + env?: Record +} + +function source(input: ParseSource) { + return input.type === "path" ? input.path : input.source +} + +function dir(input: ParseSource) { + return input.type === "path" ? path.dirname(input.path) : input.dir +} + +/** Apply {env:VAR} and {file:path} substitutions to config text. */ +export async function substitute(input: SubstituteInput) { + const missing = input.missing ?? "error" + let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { + return (input.env?.[varName] ?? process.env[varName]) || "" + }) + + const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) + if (!fileMatches.length) return text + + const configDir = dir(input) + const configSource = source(input) + let out = "" + let cursor = 0 + + for (const match of fileMatches) { + const token = match[0] + const index = match.index + out += text.slice(cursor, index) + + const lineStart = text.lastIndexOf("\n", index - 1) + 1 + const prefix = text.slice(lineStart, index).trimStart() + if (prefix.startsWith("//")) { + out += token + cursor = index + token.length + continue + } + + let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") + if (filePath.startsWith("~/")) { + filePath = path.join(os.homedir(), filePath.slice(2)) + } + + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) + const fileContent = ( + await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => { + if (missing === "empty") return "" + + const errMsg = `bad file reference: "${token}"` + if (error.code === "ENOENT") { + throw new InvalidError( + { + path: configSource, + message: errMsg + ` ${resolvedPath} does not exist`, + }, + { cause: error }, + ) + } + throw new InvalidError({ path: configSource, message: errMsg }, { cause: error }) + }) + ).trim() + + out += JSON.stringify(fileContent).slice(1, -1) + cursor = index + token.length + } + + out += text.slice(cursor) + return out +} diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..f54a878dbdae72e2be5fbbaa85d64cc7b455e810 --- /dev/null +++ b/packages/opencode/src/control-plane/types.ts @@ -0,0 +1,59 @@ +import { Schema, Struct } from "effect" +import { ProjectV2 } from "@opencode-ai/core/project" +import type { InstanceContext } from "@/project/instance-context" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import type { DeepMutable } from "@opencode-ai/core/schema" + +export const WorkspaceInfo = Schema.Struct({ + id: WorkspaceV2.ID, + type: Schema.String, + name: Schema.String, + branch: Schema.optional(Schema.NullOr(Schema.String)), + directory: Schema.optional(Schema.NullOr(Schema.String)), + extra: Schema.optional(Schema.NullOr(Schema.Unknown)), + projectID: ProjectV2.ID, +}).annotate({ identifier: "Workspace" }) +export type WorkspaceInfo = DeepMutable> + +export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"])).annotate({ + identifier: "WorkspaceListedInfo", +}) +export type WorkspaceListedInfo = DeepMutable> + +export const WorkspaceAdapterEntry = Schema.Struct({ + type: Schema.String, + name: Schema.String, + description: Schema.String, +}) +export type WorkspaceAdapterEntry = Schema.Schema.Type + +export type Target = + | { + type: "local" + directory: string + } + | { + type: "remote" + url: string | URL + headers?: HeadersInit + } + +export type WorkspaceAdapterContext = { + readonly instance?: InstanceContext + readonly workspaceID?: WorkspaceV2.ID +} + +export type WorkspaceAdapter = { + name: string + description: string + configure(info: WorkspaceInfo, context?: WorkspaceAdapterContext): WorkspaceInfo | Promise + create( + info: WorkspaceInfo, + env: Record, + from?: WorkspaceInfo, + context?: WorkspaceAdapterContext, + ): Promise + list?(context?: WorkspaceAdapterContext): WorkspaceListedInfo[] | Promise + remove(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Promise + target(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Target | Promise +} diff --git a/packages/opencode/src/control-plane/util.ts b/packages/opencode/src/control-plane/util.ts new file mode 100644 index 0000000000000000000000000000000000000000..35bc87163b7a03ffe5eec0290d5b1f0db4e5ef0f --- /dev/null +++ b/packages/opencode/src/control-plane/util.ts @@ -0,0 +1,39 @@ +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Effect } from "effect" + +export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (event: GlobalEvent) => boolean }) { + if (input.signal?.aborted) return Effect.fail(input.signal.reason ?? new Error("Request aborted")) + + return Effect.callback((resume) => { + const abort = () => { + cleanup() + resume(Effect.fail(input.signal?.reason ?? new Error("Request aborted"))) + } + + const handler = (event: GlobalEvent) => { + try { + if (!input.fn(event)) return + cleanup() + resume(Effect.void) + } catch (error) { + cleanup() + resume(Effect.fail(error)) + } + } + + const cleanup = () => { + clearTimeout(timeout) + GlobalBus.off("event", handler) + input.signal?.removeEventListener("abort", abort) + } + + const timeout = setTimeout(() => { + cleanup() + resume(Effect.fail(new Error("Timed out waiting for global event"))) + }, input.timeout) + + GlobalBus.on("event", handler) + input.signal?.addEventListener("abort", abort, { once: true }) + return Effect.sync(cleanup) + }) +} diff --git a/packages/opencode/src/control-plane/workspace-adapter-runtime.ts b/packages/opencode/src/control-plane/workspace-adapter-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..235edc9d22b2aef2228dc4a64224a3abe0d3eadf --- /dev/null +++ b/packages/opencode/src/control-plane/workspace-adapter-runtime.ts @@ -0,0 +1,51 @@ +import { Effect } from "effect" +import { EffectBridge } from "@/effect/bridge" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { getAdapter } from "./adapters" +import type { WorkspaceAdapter, WorkspaceInfo } from "./types" + +const context = Effect.gen(function* () { + return { + instance: yield* InstanceRef, + workspaceID: yield* WorkspaceRef, + } +}) + +export const target = (info: WorkspaceInfo) => + Effect.gen(function* () { + const adapter = getAdapter(info.projectID, info.type) + const ctx = yield* context + return yield* EffectBridge.fromPromise(() => adapter.target(info, ctx)) + }) + +export const configure = (adapter: WorkspaceAdapter, info: WorkspaceInfo) => + Effect.gen(function* () { + const ctx = yield* context + return yield* EffectBridge.fromPromise(() => adapter.configure(info, ctx)) + }) + +export const create = ( + adapter: WorkspaceAdapter, + info: WorkspaceInfo, + env: Record, + from?: WorkspaceInfo, +) => + Effect.gen(function* () { + const ctx = yield* context + return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, ctx)) + }) + +export const list = (adapter: WorkspaceAdapter) => + Effect.gen(function* () { + const ctx = yield* context + return yield* EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.(ctx) ?? [])) + }) + +export const remove = (info: WorkspaceInfo) => + Effect.gen(function* () { + const adapter = getAdapter(info.projectID, info.type) + const ctx = yield* context + return yield* EffectBridge.fromPromise(() => adapter.remove(info, ctx)) + }) + +export * as WorkspaceAdapterRuntime from "./workspace-adapter-runtime" diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts new file mode 100644 index 0000000000000000000000000000000000000000..52229e56392611a4ce66b851721367100417cd25 --- /dev/null +++ b/packages/opencode/src/control-plane/workspace-context.ts @@ -0,0 +1,26 @@ +import { LocalContext } from "@/util/local-context" +import type { WorkspaceV2 } from "@opencode-ai/core/workspace" + +export interface WorkspaceContext { + workspaceID: WorkspaceV2.ID | undefined +} + +const context = LocalContext.create("instance") + +export const WorkspaceContext = { + async provide(input: { workspaceID?: WorkspaceV2.ID; fn: () => R }): Promise { + return context.provide({ workspaceID: input.workspaceID }, () => input.fn()) + }, + + restore(workspaceID: WorkspaceV2.ID, fn: () => R): R { + return context.provide({ workspaceID }, fn) + }, + + get workspaceID() { + try { + return context.use().workspaceID + } catch { + return undefined + } + }, +} diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts new file mode 100644 index 0000000000000000000000000000000000000000..188cd383bb4690ad705153978fe54883380db09e --- /dev/null +++ b/packages/opencode/src/control-plane/workspace.ts @@ -0,0 +1,966 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" +import { Database } from "@opencode-ai/core/database/database" +import { asc } from "drizzle-orm" +import { eq } from "drizzle-orm" +import { inArray } from "drizzle-orm" +import { Project } from "@/project/project" +import { GlobalBus } from "@/bus/global" +import { Auth } from "@/auth" +import { EventV2 } from "@opencode-ai/core/event" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { ProjectV2 } from "@opencode-ai/core/project" +import { Slug } from "@opencode-ai/core/util/slug" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" +import { getAdapter, registeredAdapters } from "./adapters" +import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionID } from "@/session/schema" +import { NotFoundError } from "@/storage/storage" +import { errorData } from "@/util/error" +import { waitEvent } from "./util" +import { WorkspaceRef } from "@/effect/instance-ref" +import { Vcs } from "@/project/vcs" +import { InstanceStore } from "@/project/instance-store" +import { WorkspaceAdapterRuntime } from "./workspace-adapter-runtime" +import { AppNodeBuilderV1 } from "@/effect/app-node-builder-v1" +import { WorkspaceEvent } from "@opencode-ai/schema/workspace-event" + +export const Info = Schema.Struct({ + ...WorkspaceInfoSchema.fields, + timeUsed: Schema.Number, +}).annotate({ identifier: "Workspace" }) +export type Info = WorkspaceInfo & { timeUsed: number } + +export const ConnectionStatus = WorkspaceEvent.ConnectionStatus +export type ConnectionStatus = WorkspaceEvent.ConnectionStatus + +export const Event = WorkspaceEvent + +function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { + return { + id: row.id, + type: row.type, + branch: row.branch, + name: row.name, + directory: row.directory, + extra: row.extra, + projectID: row.project_id, + timeUsed: row.time_used, + } +} + +export const CreateInput = Schema.Struct({ + id: Schema.optional(WorkspaceV2.ID), + type: Info.fields.type, + branch: Info.fields.branch, + projectID: ProjectV2.ID, + extra: Schema.optional(Info.fields.extra), +}) +export type CreateInput = Schema.Schema.Type + +export const SessionWarpInput = Schema.Struct({ + workspaceID: Schema.NullOr(WorkspaceV2.ID), + sessionID: SessionID, + copyChanges: Schema.optional(Schema.Boolean), +}) +export type SessionWarpInput = Schema.Schema.Type + +export class SyncHttpError extends Schema.TaggedErrorClass()("WorkspaceSyncHttpError", { + message: Schema.String, + status: Schema.Number, + body: Schema.optional(Schema.String), +}) {} + +export class WorkspaceNotFoundError extends Schema.TaggedErrorClass()( + "WorkspaceNotFoundError", + { + message: Schema.String, + workspaceID: WorkspaceV2.ID, + }, +) {} + +export class SessionEventsNotFoundError extends Schema.TaggedErrorClass()( + "WorkspaceSessionEventsNotFoundError", + { + message: Schema.String, + sessionID: SessionID, + }, +) {} + +export class SessionWarpHttpError extends Schema.TaggedErrorClass()( + "WorkspaceSessionWarpHttpError", + { + message: Schema.String, + workspaceID: WorkspaceV2.ID, + sessionID: SessionID, + status: Schema.Number, + body: Schema.String, + }, +) {} + +export class SyncTimeoutError extends Schema.TaggedErrorClass()("WorkspaceSyncTimeoutError", { + message: Schema.String, + state: Schema.Record(Schema.String, Schema.Number), +}) {} + +export class SyncAbortedError extends Schema.TaggedErrorClass()("WorkspaceSyncAbortedError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +type CreateError = Auth.AuthError +type SessionWarpError = + | WorkspaceNotFoundError + | SessionEventsNotFoundError + | SessionWarpHttpError + | Vcs.PatchApplyError + | HttpClientError.HttpClientError +type WaitForSyncError = SyncTimeoutError | SyncAbortedError +type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError + +export interface Interface { + readonly create: (input: CreateInput) => Effect.Effect + readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect + readonly list: (project: Project.Info) => Effect.Effect + readonly syncList: (project: Project.Info) => Effect.Effect + readonly get: (id: WorkspaceV2.ID) => Effect.Effect + readonly remove: (id: WorkspaceV2.ID) => Effect.Effect + readonly status: () => Effect.Effect + readonly isSyncing: (workspaceID: WorkspaceV2.ID) => Effect.Effect + readonly waitForSync: ( + workspaceID: WorkspaceV2.ID, + state: Record, + signal?: AbortSignal, + timeout?: number, + ) => Effect.Effect + readonly startWorkspaceSyncing: (projectID: ProjectV2.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Workspace") {} + +export const use = serviceUse(Service) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.Service + const session = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const http = yield* HttpClient.HttpClient + const events = yield* EventV2Bridge.Service + const vcs = yield* Vcs.Service + const flags = yield* RuntimeFlags.Service + const fs = yield* FSUtil.Service + const { db } = yield* Database.Service + const connections = new Map() + const syncFibers = yield* FiberMap.make() + + const setStatus = (id: WorkspaceV2.ID, status: ConnectionStatus["status"]) => { + const prev = connections.get(id) + if (prev?.status === status) return + const next = { workspaceID: id, status } + connections.set(id, next) + + GlobalBus.emit("event", { + directory: "global", + workspace: id, + payload: { + type: Event.Status.type, + properties: next, + }, + }) + } + + const connectSSE = Effect.fn("Workspace.connectSSE")(function* ( + url: URL | string, + headers: HeadersInit | undefined, + ) { + const response = yield* http.execute( + HttpClientRequest.get(route(url, "/global/event"), { + headers: new Headers(headers), + accept: "text/event-stream", + }), + ) + if (response.status < 200 || response.status >= 300) { + return yield* new SyncHttpError({ + message: `Workspace sync HTTP failure: ${response.status}`, + status: response.status, + }) + } + return response.stream + }) + + const parseSSE = Effect.fn("Workspace.parseSSE")(function* ( + stream: Stream.Stream, + onEvent: (event: unknown) => Effect.Effect, + ) { + yield* stream.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.mapAccum( + () => ({ data: [] as string[], id: undefined as string | undefined, retry: 1000 }), + (state, line) => { + if (line === "") { + if (!state.data.length) return [state, []] + return [{ ...state, data: [] }, [{ data: state.data.join("\n"), id: state.id, retry: state.retry }]] + } + + const index = line.indexOf(":") + const field = index === -1 ? line : line.slice(0, index) + const value = index === -1 ? "" : line.slice(index + (line[index + 1] === " " ? 2 : 1)) + + if (field === "data") return [{ ...state, data: [...state.data, value] }, []] + if (field === "id") return [{ ...state, id: value }, []] + if (field === "retry") { + const retry = Number.parseInt(value, 10) + return [Number.isNaN(retry) ? state : { ...state, retry }, []] + } + return [state, []] + }, + { + onHalt: (state) => + state.data.length ? [{ data: state.data.join("\n"), id: state.id, retry: state.retry }] : [], + }, + ), + Stream.map((event) => { + try { + return JSON.parse(event.data) as unknown + } catch { + return { + type: "sse.message", + properties: { + data: event.data, + id: event.id || undefined, + retry: event.retry, + }, + } + } + }), + Stream.runForEach(onEvent), + ) + }) + + const runInWorkspace = (input: { + workspaceID?: WorkspaceV2.ID + local: () => Effect.Effect + remote: (input: { + workspace: Info + target: Extract + }) => HttpClientRequest.HttpClientRequest + fallback: A + response?: "json" | "text" + }) => + Effect.gen(function* () { + if (!input.workspaceID) return yield* input.local() + + const workspace = yield* get(input.workspaceID) + if (!workspace) return input.fallback + + const target = yield* WorkspaceAdapterRuntime.target(workspace) + + if (target.type === "local") { + const store = yield* InstanceStore.Service + return yield* store.provide({ directory: target.directory }, input.local()) + } + + const response = yield* http.execute(input.remote({ workspace, target })).pipe( + Effect.catch((error) => + Effect.logWarning("workspace target request failed", { + workspaceID: workspace.id, + error: errorData(error), + }).pipe(Effect.as(undefined)), + ), + ) + if (!response) return input.fallback + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))) + yield* Effect.logWarning("workspace target request failed", { + workspaceID: workspace.id, + status: response.status, + body, + }) + return input.fallback + } + + const body = input.response === "text" ? response.text : response.json + return yield* body.pipe( + Effect.map((result) => result as A), + Effect.catch((error) => + Effect.logWarning("workspace target response decode failed", { + workspaceID: workspace.id, + error: errorData(error), + }).pipe(Effect.as(input.fallback)), + ), + ) + }) + + const syncHistory = Effect.fn("Workspace.syncHistory")(function* ( + space: Info, + url: URL | string, + headers: HeadersInit | undefined, + ) { + const sessionIDs = (yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.workspace_id, space.id)) + .all() + .pipe(Effect.orDie)).map((row) => row.id) + const state = sessionIDs.length + ? Object.fromEntries( + (yield* db + .select() + .from(EventSequenceTable) + .where(inArray(EventSequenceTable.aggregate_id, sessionIDs)) + .all() + .pipe(Effect.orDie)).map((row) => [row.aggregate_id, row.seq]), + ) + : {} + + const response = yield* http.execute( + HttpClientRequest.post(route(url, "/sync/history"), { + headers: new Headers(headers), + body: HttpBody.jsonUnsafe(state), + }), + ) + + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + return yield* new SyncHttpError({ + message: `Workspace history HTTP failure: ${response.status} ${body}`, + status: response.status, + body, + }) + } + + const history = (yield* response.json) as HistoryEvent[] + + yield* Effect.forEach( + history, + (event) => + events + .replay( + { + id: EventV2.ID.make(event.id), + aggregateID: event.aggregate_id, + seq: event.seq, + type: event.type, + data: event.data, + }, + { publish: true, ownerID: space.id }, + ) + .pipe(Effect.provideService(WorkspaceRef, space.id)), + { discard: true }, + ) + }) + + const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { + const target = yield* WorkspaceAdapterRuntime.target(space) + + if (target.type === "local") return + + let attempt = 0 + + while (true) { + setStatus(space.id, "connecting") + + const stream = yield* connectSSE(target.url, target.headers).pipe( + Effect.tap(() => syncHistory(space, target.url, target.headers)), + Effect.catch((err) => + Effect.gen(function* () { + setStatus(space.id, "error") + yield* Effect.logWarning("failed to connect to global sync", { + workspace: space.name, + error: errorData(err), + }) + return null + }), + ), + ) + + if (stream) { + attempt = 0 + + setStatus(space.id, "connected") + + yield* parseSSE(stream, (evt) => + Effect.gen(function* () { + if (!evt || typeof evt !== "object" || !("payload" in evt)) return + const payload = evt.payload as { type?: string; syncEvent?: EventV2.SerializedEvent } + if (payload.type === "server.heartbeat") return + + if (payload.type === "sync" && payload.syncEvent) { + const failed = yield* events.replay(payload.syncEvent, { publish: true, ownerID: space.id }).pipe( + Effect.as(false), + Effect.catchCause((error) => + Effect.logWarning("failed to replay global event", error).pipe( + Effect.annotateLogs({ workspaceID: space.id }), + Effect.as(true), + ), + ), + ) + if (failed) return + } + + try { + const event = evt as { directory?: string; project?: string; payload: unknown } + GlobalBus.emit("event", { + directory: event.directory, + project: event.project, + workspace: space.id, + payload: event.payload, + }) + } catch (error) { + yield* Effect.logWarning("failed to emit global event", { + workspaceID: space.id, + error: errorData(error), + }) + } + }), + ) + + setStatus(space.id, "disconnected") + } + + // Back off reconnect attempts up to 2 minutes while the workspace + // stays unavailable. + yield* Effect.sleep(`${Math.min(120_000, 1_000 * 2 ** attempt)} millis`) + attempt += 1 + } + }) + + const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { + if (!flags.experimentalWorkspaces) return + + const target = yield* WorkspaceAdapterRuntime.target(space).pipe( + Effect.catch((error) => + Effect.gen(function* () { + setStatus(space.id, "error") + yield* Effect.logWarning("workspace target failed", { + workspaceID: space.id, + error: errorData(error), + }) + return null + }), + ), + ) + if (!target) return + + if (target.type === "local") { + setStatus(space.id, (yield* fs.existsSafe(target.directory)) ? "connected" : "error") + return + } + + const exists = yield* FiberMap.has(syncFibers, space.id) + if (exists && connections.get(space.id)?.status !== "error") return + + setStatus(space.id, "disconnected") + + yield* FiberMap.run( + syncFibers, + space.id, + // TODO: look into `tapError` to set the status but still + // allow the fiber to fail and automatically get removed + syncWorkspaceLoop(space).pipe( + Effect.catch((error) => + Effect.gen(function* () { + setStatus(space.id, "error") + yield* Effect.logWarning("workspace listener failed", { + workspaceID: space.id, + error: errorData(error), + }) + }), + ), + ), + ) + }) + + const stopSync = Effect.fn("Workspace.stopSync")(function* (id: WorkspaceV2.ID) { + yield* FiberMap.remove(syncFibers, id) + connections.delete(id) + }) + + const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { + const id = WorkspaceV2.ID.ascending(input.id) + const adapter = getAdapter(input.projectID, input.type) + const config = yield* WorkspaceAdapterRuntime.configure(adapter, { + ...input, + id, + name: Slug.create(), + directory: null, + extra: input.extra ?? null, + }) + + const info: Info = { + id, + type: config.type, + branch: config.branch ?? null, + name: config.name ?? null, + directory: config.directory ?? null, + extra: config.extra ?? null, + projectID: input.projectID, + timeUsed: Date.now(), + } + + yield* db + .insert(WorkspaceTable) + .values({ + id: info.id, + type: info.type, + branch: info.branch, + name: info.name, + directory: info.directory, + extra: info.extra, + project_id: info.projectID, + time_used: info.timeUsed, + }) + .run() + .pipe(Effect.orDie) + + const env = { + OPENCODE_AUTH_CONTENT: JSON.stringify(yield* auth.all()), + OPENCODE_WORKSPACE_ID: config.id, + OPENCODE_EXPERIMENTAL_WORKSPACES: "true", + OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, + } + + yield* WorkspaceAdapterRuntime.create(adapter, config, env) + yield* Effect.all( + [ + waitEvent({ + timeout: TIMEOUT, + fn(event) { + if (event.workspace === info.id && event.payload.type === Event.Status.type) { + const { status } = event.payload.properties + return status === "error" || status === "connected" + } + return false + }, + }), + startSync(info), + ], + { concurrency: 2, discard: true }, + ) + + return info + }) + + const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) { + return yield* Effect.gen(function* () { + const current = yield* db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get() + .pipe(Effect.orDie) + + if (current?.workspaceID) { + const previous = yield* get(current.workspaceID) + if (previous) { + const target = yield* WorkspaceAdapterRuntime.target(previous) + + if (target.type === "remote") { + yield* syncHistory(previous, target.url, target.headers).pipe( + Effect.catch((error) => + Effect.logWarning("session warp final source sync failed", { + workspaceID: previous.id, + sessionID: input.sessionID, + error: errorData(error), + }), + ), + ) + } else { + yield* prompt.cancel(input.sessionID) + } + + // "claim" this session so any future events coming from + // the old workspace are ignored + yield* events.claim(input.sessionID, input.workspaceID ?? previous.projectID) + } + } + + const sourcePatch = + input.copyChanges && current?.workspaceID + ? yield* runInWorkspace({ + workspaceID: current?.workspaceID ?? undefined, + local: () => vcs.diffRaw(), + remote: ({ target }) => + HttpClientRequest.get(route(target.url, "/vcs/diff/raw"), { + headers: new Headers(target.headers), + }), + fallback: "", + response: "text", + }).pipe(Effect.provide(AppNodeBuilderV1.build(InstanceStore.node))) + : "" + + if (sourcePatch) { + // Attempt to apply the file changes to the new workspace. + // We intentionally do first so if it fails we don't warp + // the session. + yield* runInWorkspace({ + workspaceID: input.workspaceID ?? undefined, + local: () => vcs.apply({ patch: sourcePatch }), + remote: ({ target }) => + HttpClientRequest.post(route(target.url, "/vcs/apply"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ patch: sourcePatch }), + }), + fallback: { applied: false }, + }).pipe(Effect.provide(AppNodeBuilderV1.build(InstanceStore.node))) + } + + if (input.workspaceID === null) { + yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: undefined }) + + return + } + + const workspaceID = input.workspaceID + const space = yield* get(workspaceID) + if (!space) + return yield* new WorkspaceNotFoundError({ + message: `Workspace not found: ${workspaceID}`, + workspaceID, + }) + + const target = yield* WorkspaceAdapterRuntime.target(space) + + if (target.type === "local") { + yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID }) + + return + } + + const rows = yield* db + .select({ + id: EventTable.id, + aggregateID: EventTable.aggregate_id, + seq: EventTable.seq, + type: EventTable.type, + data: EventTable.data, + }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, input.sessionID)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie) + if (rows.length === 0) + return yield* new SessionEventsNotFoundError({ + message: `No events found for session: ${input.sessionID}`, + sessionID: input.sessionID, + }) + + const batches = Iterable.chunksOf(rows, 10) + const total = Iterable.size(batches) + + yield* Effect.forEach( + batches, + (events, i) => + Effect.gen(function* () { + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, "/sync/replay"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ + directory: space.directory ?? "", + events, + }), + }), + ) + + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + return yield* new SessionWarpHttpError({ + message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) + } + }), + { discard: true }, + ) + + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, "/sync/steal"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }), + }), + ) + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + return yield* new SessionWarpHttpError({ + message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) + } + + yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID }) + }) + }) + + const list = Effect.fn("Workspace.list")(function* (project: Project.Info) { + if (!flags.experimentalWorkspaces) return [] + return (yield* db + .select() + .from(WorkspaceTable) + .where(eq(WorkspaceTable.project_id, project.id)) + .all() + .pipe(Effect.orDie)) + .map(fromRow) + .sort((a, b) => a.id.localeCompare(b.id)) + }) + + const syncList = Effect.fn("Workspace.syncList")(function* (project: Project.Info) { + const names = new Set((yield* list(project)).map((workspace) => workspace.name)) + const discovered = yield* Effect.forEach( + registeredAdapters(project.id), + ([type, adapter]) => + WorkspaceAdapterRuntime.list(adapter).pipe( + Effect.catchCause((error) => + Effect.logWarning("workspace adapter list failed", { type, error }).pipe(Effect.as([])), + ), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((items) => items.flat())) + + yield* Effect.forEach( + discovered, + (item) => + Effect.gen(function* () { + if (names.has(item.name)) return + names.add(item.name) + + const info: Info = { + id: WorkspaceV2.ID.ascending(), + type: item.type, + branch: item.branch, + name: item.name, + directory: item.directory, + extra: item.extra, + projectID: item.projectID, + timeUsed: Date.now(), + } + + yield* db + .insert(WorkspaceTable) + .values({ + id: info.id, + type: info.type, + branch: info.branch, + name: info.name, + directory: info.directory, + extra: info.extra, + project_id: info.projectID, + time_used: info.timeUsed, + }) + .run() + .pipe(Effect.orDie) + + yield* startSync(info) + }), + { concurrency: 1 }, + ) + }) + + const get = Effect.fn("Workspace.get")(function* (id: WorkspaceV2.ID) { + const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie) + if (!row) return + return fromRow(row) + }) + + const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceV2.ID) { + const sessions = yield* db + .select({ id: SessionTable.id, parentID: SessionTable.parent_id }) + .from(SessionTable) + .where(eq(SessionTable.workspace_id, id)) + .all() + .pipe(Effect.orDie) + const sessionIDs = new Set(sessions.map((sessionInfo) => sessionInfo.id)) + yield* Effect.forEach( + sessions.filter((sessionInfo) => !sessionInfo.parentID || !sessionIDs.has(sessionInfo.parentID)), + (sessionInfo) => + session.remove(sessionInfo.id).pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.void)), + { discard: true }, + ) + + const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie) + if (!row) return + + yield* stopSync(id) + + const info = fromRow(row) + yield* Effect.catchCause( + Effect.gen(function* () { + yield* WorkspaceAdapterRuntime.remove(info) + }), + () => Effect.logError("adapter not available when removing workspace", { type: row.type }), + ) + + yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run().pipe(Effect.orDie) + return info + }) + + const status = Effect.fn("Workspace.status")(function* () { + return [...connections.values()] + }) + + const isSyncing = Effect.fn("Workspace.isSyncing")(function* (workspaceID: WorkspaceV2.ID) { + const exists = yield* FiberMap.has(syncFibers, workspaceID) + return exists && connections.get(workspaceID)?.status !== "error" + }) + + const waitForSync = Effect.fn("Workspace.waitForSync")(function* ( + workspaceID: WorkspaceV2.ID, + state: Record, + signal?: AbortSignal, + timeout = TIMEOUT, + ) { + if (yield* synced(db, state)) return + + yield* Effect.catch( + waitUntilSynced({ db, workspaceID, state, signal, timeout }), + (): Effect.Effect => + signal?.aborted + ? Effect.fail( + new SyncAbortedError({ + message: signal.reason instanceof Error ? signal.reason.message : "Request aborted", + cause: signal.reason, + }), + ) + : Effect.fail( + new SyncTimeoutError({ + message: `Timed out waiting for sync fence: ${JSON.stringify(state)}`, + state, + }), + ), + ) + }) + + const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectV2.ID) { + if (!flags.experimentalWorkspaces) return + const rows = yield* db + .selectDistinct({ workspace: WorkspaceTable }) + .from(WorkspaceTable) + .where(eq(WorkspaceTable.project_id, projectID)) + .all() + .pipe(Effect.orDie) + + for (const { workspace } of rows) { + yield* startSync(fromRow(workspace)).pipe( + Effect.catch((error) => + Effect.sync(() => { + setStatus(workspace.id, "error") + }), + ), + Effect.forkDetach, + ) + } + }) + + return Service.of({ + create, + sessionWarp, + list, + syncList, + get, + remove, + status, + isSyncing, + waitForSync, + startWorkspaceSyncing, + }) + }), +) + +const TIMEOUT = 5000 + +type HistoryEvent = { + id: string + aggregate_id: string + seq: number + type: string + data: Record +} + +function waitUntilSynced(input: { + db: Database.Interface["db"] + workspaceID: WorkspaceV2.ID + state: Record + signal?: AbortSignal + timeout: number +}): Effect.Effect { + return Effect.suspend(() => + waitEvent({ + timeout: input.timeout, + signal: input.signal, + fn(event) { + return event.workspace === input.workspaceID || event.payload.type === "sync" + }, + }).pipe( + Effect.andThen(synced(input.db, input.state)), + Effect.flatMap((done): Effect.Effect => (done ? Effect.void : waitUntilSynced(input))), + ), + ) +} + +function synced(db: Database.Interface["db"], state: Record): Effect.Effect { + const ids = Object.keys(state) + if (ids.length === 0) return Effect.succeed(true) + + return db + .select({ + id: EventSequenceTable.aggregate_id, + seq: EventSequenceTable.seq, + }) + .from(EventSequenceTable) + .where(inArray(EventSequenceTable.aggregate_id, ids)) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => { + const done = Object.fromEntries(rows.map((row) => [row.id, row.seq])) as Record + return ids.every((id) => (done[id] ?? -1) >= state[id]) + }), + ) +} + +function route(url: string | URL, path: string) { + const next = new URL(url) + next.pathname = `${next.pathname.replace(/\/$/, "")}${path}` + next.search = "" + next.hash = "" + return next +} + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + Auth.node, + Session.node, + SessionPrompt.node, + httpClient, + EventV2Bridge.node, + Vcs.node, + RuntimeFlags.node, + FSUtil.node, + Database.node, + ], +}) + +export * as Workspace from "./workspace" diff --git a/packages/opencode/src/effect/app-node-builder-v1.ts b/packages/opencode/src/effect/app-node-builder-v1.ts new file mode 100644 index 0000000000000000000000000000000000000000..7573475d9a9fb8dffb48c0bf6688d01a1e7a7b7d --- /dev/null +++ b/packages/opencode/src/effect/app-node-builder-v1.ts @@ -0,0 +1,12 @@ +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" + +const bootstrapReplacement = [InstanceStore.bootstrapNode, InstanceBootstrap.node] as const + +export function build(root: LayerNode.Node, replacements: LayerNode.Replacements = []) { + return AppNodeBuilder.build(root, replacements.concat([bootstrapReplacement])) +} + +export * as AppNodeBuilderV1 from "./app-node-builder-v1" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..d17326966f92a808bd5112f3dfb326f9b2da3dff --- /dev/null +++ b/packages/opencode/src/effect/app-runtime.ts @@ -0,0 +1,135 @@ +import { Layer, ManagedRuntime } from "effect" +import { attach } from "./run-service" +import * as Observability from "@opencode-ai/core/observability" + +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Database } from "@opencode-ai/core/database/database" +import { Auth } from "@/auth" +import { Account } from "@/account/account" +import { Config } from "@/config/config" +import { Git } from "@/git" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { Storage } from "@/storage/storage" +import { Snapshot } from "@/snapshot" +import { Plugin } from "@/plugin" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { Provider } from "@/provider/provider" +import { ProviderAuth } from "@/provider/auth" +import { Agent } from "@/agent/agent" +import { Skill } from "@/skill" +import { Discovery } from "@/skill/discovery" +import { Question } from "@/question" +import { Permission } from "@/permission" +import { Todo } from "@/session/todo" +import { Session } from "@/session/session" +import { SessionStatus } from "@/session/status" +import { SessionRunState } from "@/session/run-state" +import { SessionProcessor } from "@/session/processor" +import { SessionCompaction } from "@/session/compaction" +import { SessionRevert } from "@/session/revert" +import { SessionSummary } from "@/session/summary" +import { SessionPrompt } from "@/session/prompt" +import { Instruction } from "@/session/instruction" +import { LLM } from "@/session/llm" +import { LSP } from "@/lsp/lsp" +import { MCP } from "@/mcp" +import { McpAuth } from "@/mcp/auth" +import { Command } from "@/command" +import { Truncate } from "@/tool/truncate" +import { ToolRegistry } from "@/tool/registry" +import { Format } from "@/format" +import { InstanceStore } from "@/project/instance-store" +import { Project } from "@/project/project" +import { Vcs } from "@/project/vcs" +import { Workspace } from "@/control-plane/workspace" +import { Worktree } from "@/worktree" +import { Installation } from "@/installation" +import { ShareNext } from "@/share/share-next" +import { SessionShare } from "@/share/session" +import { Npm } from "@opencode-ai/core/npm" +import { memoMap } from "@opencode-ai/core/effect/memo-map" +import { BackgroundJob } from "@/background/job" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilderV1 } from "./app-node-builder-v1" +import { SessionProjector } from "@opencode-ai/core/session/projector" + +export const AppLayer = AppNodeBuilderV1.build( + LayerNode.group([ + Npm.node, + FSUtil.node, + Database.node, + Auth.node, + Account.node, + Config.node, + Git.node, + Storage.node, + Snapshot.node, + Plugin.node, + ModelsDev.node, + Provider.node, + ProviderAuth.node, + Agent.node, + Skill.node, + Discovery.node, + Question.node, + Permission.node, + Todo.node, + Session.node, + SessionProjector.node, + SessionStatus.node, + BackgroundJob.node, + RuntimeFlags.node, + EventV2Bridge.node, + SessionRunState.node, + SessionProcessor.node, + SessionCompaction.node, + SessionRevert.node, + SessionSummary.node, + SessionPrompt.node, + Instruction.node, + LLM.node, + LSP.node, + MCP.node, + McpAuth.node, + Command.node, + Truncate.node, + ToolRegistry.node, + Format.node, + InstanceStore.node, + Project.node, + Vcs.node, + Workspace.node, + Worktree.node, + Installation.node, + ShareNext.node, + SessionShare.node, + ]), +).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer)) + +const rt = ManagedRuntime.make(AppLayer, { memoMap }) +type Runtime = Pick + +/** Services provided by AppRuntime — i.e. what an Effect run via AppRuntime.runPromise can yield. */ +export type AppServices = ManagedRuntime.ManagedRuntime.Services +const wrap = (effect: Parameters[0]) => attach(effect as never) as never + +export const AppRuntime: Runtime = { + runSync(effect) { + return rt.runSync(wrap(effect)) + }, + runPromise(effect, options) { + return rt.runPromise(wrap(effect), options) + }, + runPromiseExit(effect, options) { + return rt.runPromiseExit(wrap(effect), options) + }, + runFork(effect) { + return rt.runFork(wrap(effect)) + }, + runCallback(effect) { + return rt.runCallback(wrap(effect)) + }, + dispose: () => rt.dispose(), +} diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..57fe43b46ee34548524b2dc422bda8b5d765103f --- /dev/null +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -0,0 +1,19 @@ +import { Layer, ManagedRuntime } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +import { Plugin } from "@/plugin" +import { LSP } from "@/lsp/lsp" +import { Format } from "@/format" +import { ShareNext } from "@/share/share-next" +import { Vcs } from "@/project/vcs" +import { Snapshot } from "@/snapshot" +import { Config } from "@/config/config" +import * as Observability from "@opencode-ai/core/observability" +import { memoMap } from "@opencode-ai/core/effect/memo-map" + +export const BootstrapLayer = AppNodeBuilder.build( + LayerNode.group([Config.node, Plugin.node, ShareNext.node, Format.node, LSP.node, Vcs.node, Snapshot.node]), +).pipe(Layer.provide(Observability.layer)) + +export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts new file mode 100644 index 0000000000000000000000000000000000000000..a51c2938d869053a7d0db35fc9d3c42a83d338b6 --- /dev/null +++ b/packages/opencode/src/effect/bridge.ts @@ -0,0 +1,84 @@ +import { Context, Effect, Exit, Fiber } from "effect" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import type { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { InstanceRef, WorkspaceRef } from "./instance-ref" +import { attachWith } from "./run-service" + +export interface Shape { + readonly promise: (effect: Effect.Effect) => Promise + readonly fork: (effect: Effect.Effect) => Fiber.Fiber + readonly run: (effect: Effect.Effect) => Effect.Effect + readonly bind: (fn: (...args: Args) => Result) => (...args: Args) => Result +} + +function restoreWorkspace(workspace: WorkspaceV2.ID | undefined, fn: () => R): R { + if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn) + return fn() +} + +function captureSync() { + const fiber = Fiber.getCurrent() + const instance = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined + const workspace = + (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined) ?? WorkspaceContext.workspaceID + return { instance, workspace } +} + +export const bind = (fn: (...args: Args) => Result) => { + const captured = captureSync() + return (...args: Args) => + restoreWorkspace(captured.workspace, () => + Effect.runSync( + attachWith( + Effect.sync(() => fn(...args)), + captured, + ), + ), + ) +} + +/** + * Bridge from Effect into a Promise-returning JS callback while preserving + * `WorkspaceContext` AsyncLocalStorage for callback code that still reads it. + * `InstanceRef` is captured for effects run through the returned bridge APIs; + * plain JS callbacks that need it should receive the ref explicitly. + * + * Mirrors `Effect.promise` but restores workspace ALS first. + */ +export const fromPromise = (fn: () => Promise | T): Effect.Effect => + Effect.gen(function* () { + const workspace = yield* WorkspaceRef + return yield* Effect.promise(() => Promise.resolve(restoreWorkspace(workspace, () => fn()))) + }) + +export function make(): Effect.Effect { + return Effect.gen(function* () { + const ctx = yield* Effect.context() + const captured = captureSync() + const instance = (yield* InstanceRef) ?? captured.instance + const workspace = (yield* WorkspaceRef) ?? captured.workspace + const wrap = (effect: Effect.Effect) => + attachWith(effect.pipe(Effect.provide(ctx)) as Effect.Effect, { instance, workspace }) + + return { + promise: (effect: Effect.Effect) => + restoreWorkspace(workspace, () => Effect.runPromise(wrap(effect))), + fork: (effect: Effect.Effect) => + restoreWorkspace(workspace, () => Effect.runFork(wrap(effect))), + run: (effect: Effect.Effect) => + Effect.callback((resume) => { + restoreWorkspace(workspace, () => + Effect.runPromiseExit(wrap(effect)).then((exit) => + resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)), + ), + ) + }), + bind: + (fn: (...args: Args) => Result) => + (...args: Args) => + restoreWorkspace(workspace, () => Effect.runSync(wrap(Effect.sync(() => fn(...args))))), + } satisfies Shape + }) +} + +export * as EffectBridge from "./bridge" diff --git a/packages/opencode/src/effect/config-service.ts b/packages/opencode/src/effect/config-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..516c040f577c38662fcdcfc820467c8e9d3936f2 --- /dev/null +++ b/packages/opencode/src/effect/config-service.ts @@ -0,0 +1,67 @@ +import { Config, Context, Effect, Layer } from "effect" + +type ConfigMap = Record> + +/** + * The service shape inferred from an object of Effect `Config` definitions. + */ +export type Shape = { + readonly [Key in keyof Fields]: Config.Success +} + +/** + * A Context service class with generated layers for config-backed services. + */ +export type ServiceClass = Context.ServiceClass & { + /** Provide already-parsed config, useful in tests. */ + readonly configLayer: (input: Service) => Layer.Layer + /** Parse config once from the active Effect ConfigProvider and provide the service. */ + readonly layer: Layer.Layer +} + +/** + * Create a Context service whose implementation is derived from Effect `Config`. + * + * This keeps Effect `Config` as the source of truth for env names, defaults, and + * validation while generating a typed service plus convenient production/test + * layers. + * + * ```ts + * class ServerAuthConfig extends ConfigService.Service()( + * "@opencode/ServerAuthConfig", + * { + * password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option), + * username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")), + * }, + * ) {} + * + * const live = ServerAuthConfig.layer + * const test = ServerAuthConfig.configLayer({ password: Option.some("secret"), username: "kit" }) + * ``` + */ +export const Service = + () => + (id: Id, fields: Fields) => { + class ConfigTag extends Context.Service>()(id) { + static configLayer(input: Shape) { + return Layer.succeed(this, this.of(input)) + } + + static get layer() { + const tag = this + return Layer.effect( + tag, + Effect.gen(function* () { + const config = yield* Config.all(fields) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the field shape, but its conditional return type also supports iterable inputs. + return tag.of(config as Shape) + }), + ) + } + } + + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers. + return ConfigTag as ServiceClass> + } + +export * as ConfigService from "./config-service" diff --git a/packages/opencode/src/effect/instance-ref.ts b/packages/opencode/src/effect/instance-ref.ts new file mode 100644 index 0000000000000000000000000000000000000000..49636c1f49952bbc9c559c1e47e4e931adabb82a --- /dev/null +++ b/packages/opencode/src/effect/instance-ref.ts @@ -0,0 +1,11 @@ +import { Context } from "effect" +import type { InstanceContext } from "@/project/instance-context" +import type { WorkspaceV2 } from "@opencode-ai/core/workspace" + +export const InstanceRef = Context.Reference("~opencode/InstanceRef", { + defaultValue: () => undefined, +}) + +export const WorkspaceRef = Context.Reference("~opencode/WorkspaceRef", { + defaultValue: () => undefined, +}) diff --git a/packages/opencode/src/effect/instance-registry.ts b/packages/opencode/src/effect/instance-registry.ts new file mode 100644 index 0000000000000000000000000000000000000000..59c556e0447eec658b56fda630454a57f1f6d3c6 --- /dev/null +++ b/packages/opencode/src/effect/instance-registry.ts @@ -0,0 +1,12 @@ +const disposers = new Set<(directory: string) => Promise>() + +export function registerDisposer(disposer: (directory: string) => Promise) { + disposers.add(disposer) + return () => { + disposers.delete(disposer) + } +} + +export async function disposeInstance(directory: string) { + await Promise.allSettled([...disposers].map((disposer) => disposer(directory))) +} diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..b40168fbc2d7a53110cd8ba115ba8ee6acd91b80 --- /dev/null +++ b/packages/opencode/src/effect/instance-state.ts @@ -0,0 +1,69 @@ +import { Effect, ScopedCache, Scope } from "effect" +import type { InstanceContext } from "@/project/instance-context" +import { InstanceRef, WorkspaceRef } from "./instance-ref" +import { registerDisposer } from "./instance-registry" +import { WorkspaceContext } from "@/control-plane/workspace-context" + +const TypeId = "~opencode/InstanceState" + +export interface InstanceState { + readonly [TypeId]: typeof TypeId + readonly cache: ScopedCache.ScopedCache +} + +export const context = Effect.gen(function* () { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die(new Error("InstanceRef not provided")) + return ctx +}) + +export const workspaceID = Effect.gen(function* () { + return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID +}) + +export const directory = Effect.map(context, (ctx) => ctx.directory) + +export const make = ( + init: (ctx: InstanceContext) => Effect.Effect, +): Effect.Effect>, never, R | Scope.Scope> => + Effect.gen(function* () { + const cache = yield* ScopedCache.make({ + capacity: Number.POSITIVE_INFINITY, + lookup: () => + Effect.gen(function* () { + return yield* init(yield* context) + }), + }) + + const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory))) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + return { + [TypeId]: TypeId, + cache, + } + }) + +export const get = (self: InstanceState) => + Effect.gen(function* () { + return yield* ScopedCache.get(self.cache, yield* directory) + }) + +export const use = (self: InstanceState, select: (value: A) => B) => Effect.map(get(self), select) + +export const useEffect = ( + self: InstanceState, + select: (value: A) => Effect.Effect, +) => Effect.flatMap(get(self), select) + +export const has = (self: InstanceState) => + Effect.gen(function* () { + return yield* ScopedCache.has(self.cache, yield* directory) + }) + +export const invalidate = (self: InstanceState) => + Effect.gen(function* () { + return yield* ScopedCache.invalidate(self.cache, yield* directory) + }) + +export * as InstanceState from "./instance-state" diff --git a/packages/opencode/src/effect/promise.ts b/packages/opencode/src/effect/promise.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7918796ad8995d024e5394128c08f0426567c19 --- /dev/null +++ b/packages/opencode/src/effect/promise.ts @@ -0,0 +1,17 @@ +import { Cause, Effect } from "effect" + +export function refineRejection( + evaluate: (signal: AbortSignal) => PromiseLike, + refine: (cause: unknown) => E | undefined, +) { + return Effect.tryPromise(evaluate).pipe( + Effect.catch((error) => { + const cause = Cause.isUnknownError(error) ? error.cause : error + const refined = refine(cause) + if (refined !== undefined) return Effect.fail(refined) + return Effect.die(cause) + }), + ) +} + +export * as EffectPromise from "./promise" diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfcc50081e3eb092f2bc19bce72a352671d2a758 --- /dev/null +++ b/packages/opencode/src/effect/run-service.ts @@ -0,0 +1,47 @@ +import { Effect, Fiber, Layer, ManagedRuntime } from "effect" +import * as Context from "effect/Context" +import { InstanceRef, WorkspaceRef } from "./instance-ref" +import * as Observability from "@opencode-ai/core/observability" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import type { InstanceContext } from "@/project/instance-context" +import { memoMap } from "@opencode-ai/core/effect/memo-map" + +type Refs = { + instance?: InstanceContext + workspace?: string +} + +export function attachWith(effect: Effect.Effect, refs: Refs): Effect.Effect { + if (!refs.instance && !refs.workspace) return effect + if (!refs.instance) return effect.pipe(Effect.provideService(WorkspaceRef, refs.workspace)) + if (!refs.workspace) return effect.pipe(Effect.provideService(InstanceRef, refs.instance)) + return effect.pipe( + Effect.provideService(InstanceRef, refs.instance), + Effect.provideService(WorkspaceRef, refs.workspace), + ) +} + +export function attach(effect: Effect.Effect): Effect.Effect { + const workspace = WorkspaceContext.workspaceID + const fiber = Fiber.getCurrent() + return attachWith(effect, { + instance: fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined, + workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined), + }) +} + +export function makeRuntime(service: Context.Service, layer: Layer.Layer) { + let rt: ManagedRuntime.ManagedRuntime | undefined + const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer), { memoMap })) + + return { + runSync: (fn: (svc: S) => Effect.Effect) => getRuntime().runSync(attach(service.use(fn))), + runPromiseExit: (fn: (svc: S) => Effect.Effect, options?: Effect.RunOptions) => + getRuntime().runPromiseExit(attach(service.use(fn)), options), + runPromise: (fn: (svc: S) => Effect.Effect, options?: Effect.RunOptions) => + getRuntime().runPromise(attach(service.use(fn)), options), + runFork: (fn: (svc: S) => Effect.Effect) => getRuntime().runFork(attach(service.use(fn))), + runCallback: (fn: (svc: S) => Effect.Effect) => + getRuntime().runCallback(attach(service.use(fn))), + } +} diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts new file mode 100644 index 0000000000000000000000000000000000000000..f21a61c97e5713e8d173cc271ad2fa50475a9ac7 --- /dev/null +++ b/packages/opencode/src/effect/runner.ts @@ -0,0 +1,217 @@ +import { Cause, Deferred, Effect, Exit, Fiber, Latch, Schema, Scope, SynchronizedRef } from "effect" + +export interface Runner { + readonly state: State + readonly busy: boolean + readonly ensureRunning: (work: Effect.Effect) => Effect.Effect + readonly startShell: (work: Effect.Effect, ready?: Latch.Latch) => Effect.Effect + readonly cancel: Effect.Effect +} + +export class Cancelled extends Schema.TaggedErrorClass()("RunnerCancelled", {}) {} +export class Busy extends Schema.TaggedErrorClass()("RunnerBusy", {}) {} + +interface RunHandle { + id: number + done: Deferred.Deferred + fiber: Fiber.Fiber +} + +interface ShellHandle { + id: number + cancelled: Deferred.Deferred + ready?: Latch.Latch + fiber: Fiber.Fiber +} + +interface PendingHandle { + id: number + done: Deferred.Deferred + work: Effect.Effect +} + +export type State = + | { readonly _tag: "Idle" } + | { readonly _tag: "Running"; readonly run: RunHandle } + | { readonly _tag: "Shell"; readonly shell: ShellHandle } + | { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle; readonly run: PendingHandle } + +export const make = ( + scope: Scope.Scope, + opts?: { + onIdle?: Effect.Effect + onBusy?: Effect.Effect + onInterrupt?: Effect.Effect + }, +): Runner => { + const ref = SynchronizedRef.makeUnsafe>({ _tag: "Idle" }) + const idle = opts?.onIdle ?? Effect.void + const onBusy = opts?.onBusy ?? Effect.void + const onInterrupt = opts?.onInterrupt + let ids = 0 + + const state = () => SynchronizedRef.getUnsafe(ref) + const next = () => { + ids += 1 + return ids + } + + const complete = (done: Deferred.Deferred, exit: Exit.Exit) => + Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) + ? Deferred.fail(done, new Cancelled()).pipe(Effect.asVoid) + : Deferred.done(done, exit).pipe(Effect.asVoid) + + const awaitDone = (done: Deferred.Deferred) => + Deferred.await(done).pipe(Effect.catchTag("RunnerCancelled", (e) => onInterrupt ?? Effect.die(e))) + + const idleIfCurrent = () => + SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten) + + const finishRun = (id: number, done: Deferred.Deferred, exit: Exit.Exit) => + SynchronizedRef.modify( + ref, + (st) => + [ + Effect.gen(function* () { + if (st._tag === "Running" && st.run.id === id) yield* idle + yield* complete(done, exit) + }), + st._tag === "Running" && st.run.id === id ? ({ _tag: "Idle" } as const) : st, + ] as const, + ).pipe(Effect.flatten) + + const startRun = (work: Effect.Effect, done: Deferred.Deferred) => + Effect.gen(function* () { + const id = next() + const fiber = yield* work.pipe( + Effect.onExit((exit) => finishRun(id, done, exit)), + Effect.forkIn(scope), + ) + return { id, done, fiber } satisfies RunHandle + }) + + const finishShell = (id: number) => + SynchronizedRef.modifyEffect( + ref, + Effect.fnUntraced(function* (st) { + if (st._tag === "Shell" && st.shell.id === id) { + return [idle, { _tag: "Idle" }] as const + } + if (st._tag === "ShellThenRun" && st.shell.id === id) { + const run = yield* startRun(st.run.work, st.run.done) + return [Effect.void, { _tag: "Running", run }] as const + } + return [Effect.void, st] as const + }), + ).pipe(Effect.flatten) + + const stopShell = (shell: ShellHandle) => + Effect.gen(function* () { + if (shell.ready) yield* shell.ready.await.pipe(Effect.exit, Effect.asVoid) + yield* Deferred.succeed(shell.cancelled, undefined).pipe(Effect.asVoid) + yield* Fiber.interrupt(shell.fiber) + }) + + const ensureRunning = (work: Effect.Effect) => + SynchronizedRef.modifyEffect( + ref, + Effect.fnUntraced(function* (st) { + switch (st._tag) { + case "Running": + case "ShellThenRun": + return [awaitDone(st.run.done), st] as const + case "Shell": { + const run = { + id: next(), + done: yield* Deferred.make(), + work, + } satisfies PendingHandle + return [awaitDone(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const + } + case "Idle": { + const done = yield* Deferred.make() + const run = yield* startRun(work, done) + return [awaitDone(done), { _tag: "Running", run }] as const + } + } + }), + ).pipe(Effect.flatten) + + const startShell = (work: Effect.Effect, ready?: Latch.Latch): Effect.Effect => + SynchronizedRef.modifyEffect( + ref, + Effect.fnUntraced(function* (st) { + if (st._tag !== "Idle") { + const reject: Effect.Effect = Effect.fail(new Busy()) + return [reject, st] as const + } + yield* onBusy + const id = next() + const cancelled = yield* Deferred.make() + const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) + const shell = { id, cancelled, ready, fiber } satisfies ShellHandle + return [ + Effect.gen(function* () { + const exit = yield* Fiber.await(fiber) + if (Exit.isSuccess(exit)) return exit.value + if ( + Cause.hasInterruptsOnly(exit.cause) || + ((yield* Deferred.isDone(cancelled)) && Cause.hasInterrupts(exit.cause) && !Cause.hasDies(exit.cause)) + ) { + if (onInterrupt) return yield* onInterrupt + return yield* Effect.die(new Cancelled()) + } + return yield* Effect.failCause(exit.cause) + }), + { _tag: "Shell", shell }, + ] as const + }), + ).pipe(Effect.flatten) + + const cancel = SynchronizedRef.modify(ref, (st) => { + switch (st._tag) { + case "Idle": + return [Effect.void, st] as const + case "Running": + return [ + Effect.gen(function* () { + yield* Fiber.interrupt(st.run.fiber) + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + yield* idleIfCurrent() + }), + { _tag: "Idle" } as const, + ] as const + case "Shell": + return [ + Effect.gen(function* () { + yield* stopShell(st.shell) + yield* idleIfCurrent() + }), + { _tag: "Idle" } as const, + ] as const + case "ShellThenRun": + return [ + Effect.gen(function* () { + yield* stopShell(st.shell) + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + yield* idleIfCurrent() + }), + { _tag: "Idle" } as const, + ] as const + } + }).pipe(Effect.flatten) + + return { + get state() { + return state() + }, + get busy() { + return state()._tag !== "Idle" + }, + ensureRunning, + startShell, + cancel, + } +} + +export * as Runner from "./runner" diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts new file mode 100644 index 0000000000000000000000000000000000000000..65e02f076360bda82e5f6820434cee10b5a687f8 --- /dev/null +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -0,0 +1,78 @@ +import { Config, ConfigProvider, Context, Effect, Layer, Option } from "effect" +import { ConfigService } from "@/effect/config-service" + +const bool = (name: string) => Config.boolean(name).pipe(Config.withDefault(false)) +const positiveInteger = (name: string) => + Config.number(name).pipe( + Config.map((value) => (Number.isInteger(value) && value > 0 ? value : undefined)), + Config.orElse(() => Config.succeed(undefined)), + ) +const experimental = bool("OPENCODE_EXPERIMENTAL") +const enabledByExperimental = (name: string) => + Config.all({ experimental, enabled: Config.boolean(name).pipe(Config.option) }).pipe( + Config.map((flags) => Option.getOrElse(flags.enabled, () => flags.experimental)), + ) + +export class Service extends ConfigService.Service()("@opencode/RuntimeFlags", { + autoShare: bool("OPENCODE_AUTO_SHARE"), + pure: bool("OPENCODE_PURE"), + disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"), + disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"), + disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"), + disableLspDownload: bool("OPENCODE_DISABLE_LSP_DOWNLOAD"), + disableClaudeCodePrompt: Config.all({ + broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"), + direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), + }).pipe(Config.map((flags) => flags.broad || flags.direct)), + disableClaudeCodeSkills: Config.all({ + broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"), + direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"), + }).pipe(Config.map((flags) => flags.broad || flags.direct)), + enableExa: Config.all({ + experimental, + enabled: bool("OPENCODE_ENABLE_EXA"), + legacy: bool("OPENCODE_EXPERIMENTAL_EXA"), + }).pipe(Config.map((flags) => flags.experimental || flags.enabled || flags.legacy)), + enableParallel: Config.all({ + enabled: bool("OPENCODE_ENABLE_PARALLEL"), + legacy: bool("OPENCODE_EXPERIMENTAL_PARALLEL"), + }).pipe(Config.map((flags) => flags.enabled || flags.legacy)), + enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"), + enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"), + experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"), + experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"), + experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"), + experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"), + experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"), + experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"), + experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"), + experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), + experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), + experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"), + outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), + bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), + experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"), + experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"), + client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), +}) {} + +export type Info = Context.Service.Shape + +const emptyConfigLayer = Service.layer.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({}))), + Layer.orDie, +) + +export const layer = (overrides: Partial = {}) => + Layer.effect( + Service, + Effect.gen(function* () { + const flags = yield* Service + return Service.of({ ...flags, ...overrides }) + }), + ).pipe(Layer.provide(emptyConfigLayer)) + +export const node = LayerNode.make({ service: Service, layer: Service.layer.pipe(Layer.orDie), deps: [] }) + +export * as RuntimeFlags from "./runtime-flags" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" diff --git a/packages/opencode/src/env/index.ts b/packages/opencode/src/env/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5879f27fe37ef5045a8193b0267447de037bc23c --- /dev/null +++ b/packages/opencode/src/env/index.ts @@ -0,0 +1,41 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Context, Effect, Layer } from "effect" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { InstanceState } from "@/effect/instance-state" + +type State = Record + +export interface Interface { + readonly get: (key: string) => Effect.Effect + readonly all: () => Effect.Effect + readonly set: (key: string, value: string) => Effect.Effect + readonly remove: (key: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Env") {} + +export const use = serviceUse(Service) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make(Effect.fn("Env.state")(() => Effect.succeed({ ...process.env }))) + + const get = Effect.fn("Env.get")((key: string) => InstanceState.use(state, (env) => env[key])) + const all = Effect.fn("Env.all")(() => InstanceState.get(state)) + const set = Effect.fn("Env.set")(function* (key: string, value: string) { + const env = yield* InstanceState.get(state) + env[key] = value + }) + const remove = Effect.fn("Env.remove")(function* (key: string) { + const env = yield* InstanceState.get(state) + delete env[key] + }) + + return Service.of({ get, all, set, remove }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [] }) + +export * as Env from "." diff --git a/packages/opencode/src/event-manifest.ts b/packages/opencode/src/event-manifest.ts new file mode 100644 index 0000000000000000000000000000000000000000..b84b3f29798b0585b54d4345a60dde1ddfb9d954 --- /dev/null +++ b/packages/opencode/src/event-manifest.ts @@ -0,0 +1,3 @@ +export * as EventManifest from "./event-manifest" + +export { Definitions, Durable, Latest } from "@opencode-ai/schema/event-manifest" diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e65f2752df7cdc1d0a4946cb100fda8e89a8715 --- /dev/null +++ b/packages/opencode/src/event-v2-bridge.ts @@ -0,0 +1,71 @@ +// Opencode publish boundary for core events. Attach routed instance location +// so direct EventV2 consumers can isolate directory/workspace streams. +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { GlobalBus } from "@/bus/global" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Context, Effect, Layer } from "effect" + +export class Service extends Context.Service()("@opencode/EventV2Bridge") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + + const publish: EventV2.Interface["publish"] = (definition, data, options) => + Effect.gen(function* () { + if (options?.location) return yield* events.publish(definition, data, options) + const ctx = yield* InstanceRef + if (!ctx) return yield* events.publish(definition, data, options) + const workspaceID = yield* WorkspaceRef + return yield* events.publish(definition, data, { + ...options, + location: new Location.Info({ + directory: AbsolutePath.make(ctx.directory), + ...(workspaceID ? { workspaceID } : {}), + project: { id: Project.ID.make(ctx.project.id), directory: AbsolutePath.make(ctx.worktree) }, + }), + }) + }) + + const unsubscribe = yield* events.listen((event) => + Effect.gen(function* () { + const ctx = yield* InstanceRef + const workspaceID = (yield* WorkspaceRef) ?? event.location?.workspaceID + GlobalBus.emit("event", { + directory: event.location?.directory ?? ctx?.directory, + project: ctx?.project.id, + workspace: workspaceID, + payload: { id: event.id, type: event.type, properties: event.data }, + }) + if (event.durable === undefined) return + GlobalBus.emit("event", { + directory: event.location?.directory ?? ctx?.directory, + project: ctx?.project.id, + workspace: workspaceID, + payload: { + type: "sync", + syncEvent: { + id: event.id, + type: EventV2.versionedType(event.type, event.durable.version), + seq: event.durable.seq, + aggregateID: event.durable.aggregateID, + data: event.data, + }, + }, + }) + }), + ) + yield* Effect.addFinalizer(() => unsubscribe) + + return Service.of({ ...events, publish }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2.node] }) + +export * as EventV2Bridge from "./event-v2-bridge" diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts new file mode 100644 index 0000000000000000000000000000000000000000..3be6025d30f3b394c88125ed9855622a79a935e6 --- /dev/null +++ b/packages/opencode/src/format/formatter.ts @@ -0,0 +1,404 @@ +import { Npm } from "@opencode-ai/core/npm" +import type { InstanceContext } from "../project/instance-context" +import { Filesystem } from "@/util/filesystem" +import { Process } from "@/util/process" +import { which } from "@opencode-ai/core/util/which" + +export interface Context extends Pick { + experimentalOxfmt: boolean +} + +export interface Info { + name: string + environment?: Record + extensions: string[] + enabled(context: Context): Promise +} + +export const gofmt: Info = { + name: "gofmt", + extensions: [".go"], + async enabled() { + const match = which("gofmt") + if (!match) return false + return [match, "-w", "$FILE"] + }, +} + +export const mix: Info = { + name: "mix", + extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], + async enabled() { + const match = which("mix") + if (!match) return false + return [match, "format", "$FILE"] + }, +} + +export const prettier: Info = { + name: "prettier", + environment: { + BUN_BE_BUN: "1", + }, + extensions: [ + ".js", + ".jsx", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".mts", + ".cts", + ".html", + ".htm", + ".css", + ".scss", + ".sass", + ".less", + ".vue", + ".svelte", + ".json", + ".jsonc", + ".yaml", + ".yml", + ".toml", + ".xml", + ".md", + ".mdx", + ".graphql", + ".gql", + ], + async enabled(context) { + const items = await Filesystem.findUp("package.json", context.directory, context.worktree) + for (const item of items) { + const json = await Filesystem.readJson<{ + dependencies?: Record + devDependencies?: Record + }>(item) + if (json.dependencies?.prettier || json.devDependencies?.prettier) { + const bin = await Npm.which("prettier") + if (bin) return [bin, "--write", "$FILE"] + } + } + return false + }, +} + +export const oxfmt: Info = { + name: "oxfmt", + environment: { + BUN_BE_BUN: "1", + }, + extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"], + async enabled(context) { + if (!context.experimentalOxfmt) return false + const items = await Filesystem.findUp("package.json", context.directory, context.worktree) + for (const item of items) { + const json = await Filesystem.readJson<{ + dependencies?: Record + devDependencies?: Record + }>(item) + if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) { + const bin = await Npm.which("oxfmt") + if (bin) return [bin, "$FILE"] + } + } + return false + }, +} + +export const biome: Info = { + name: "biome", + environment: { + BUN_BE_BUN: "1", + }, + extensions: [ + ".js", + ".jsx", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".mts", + ".cts", + ".html", + ".htm", + ".css", + ".scss", + ".sass", + ".less", + ".vue", + ".svelte", + ".json", + ".jsonc", + ".yaml", + ".yml", + ".toml", + ".xml", + ".md", + ".mdx", + ".graphql", + ".gql", + ], + async enabled(context) { + const configs = ["biome.json", "biome.jsonc"] + for (const config of configs) { + const found = await Filesystem.findUp(config, context.directory, context.worktree) + if (found.length > 0) { + const bin = await Npm.which("@biomejs/biome") + if (bin) return [bin, "format", "--write", "$FILE"] + } + } + return false + }, +} + +export const zig: Info = { + name: "zig", + extensions: [".zig", ".zon"], + async enabled() { + const match = which("zig") + if (!match) return false + return [match, "fmt", "$FILE"] + }, +} + +export const clang: Info = { + name: "clang-format", + extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], + async enabled(context) { + const items = await Filesystem.findUp(".clang-format", context.directory, context.worktree) + if (items.length > 0) { + const match = which("clang-format") + if (match) return [match, "-i", "$FILE"] + } + return false + }, +} + +export const ktlint: Info = { + name: "ktlint", + extensions: [".kt", ".kts"], + async enabled() { + const match = which("ktlint") + if (!match) return false + return [match, "-F", "$FILE"] + }, +} + +export const ruff: Info = { + name: "ruff", + extensions: [".py", ".pyi"], + async enabled(context) { + if (!which("ruff")) return false + const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"] + for (const config of configs) { + const found = await Filesystem.findUp(config, context.directory, context.worktree) + if (found.length > 0) { + if (config === "pyproject.toml") { + const content = await Filesystem.readText(found[0]) + if (content.includes("[tool.ruff]")) return ["ruff", "format", "$FILE"] + } else { + return ["ruff", "format", "$FILE"] + } + } + } + const deps = ["requirements.txt", "pyproject.toml", "Pipfile"] + for (const dep of deps) { + const found = await Filesystem.findUp(dep, context.directory, context.worktree) + if (found.length > 0) { + const content = await Filesystem.readText(found[0]) + if (content.includes("ruff")) return ["ruff", "format", "$FILE"] + } + } + return false + }, +} + +export const rlang: Info = { + name: "air", + extensions: [".R"], + async enabled() { + const air = which("air") + if (air == null) return false + + const output = await Process.text([air, "--help"], { nothrow: true }) + + // Check for "Air: An R language server and formatter" + const firstLine = output.text.split("\n")[0] + const hasR = firstLine.includes("R language") + const hasFormatter = firstLine.includes("formatter") + if (output.code === 0 && hasR && hasFormatter) return [air, "format", "$FILE"] + return false + }, +} + +export const uvformat: Info = { + name: "uv", + extensions: [".py", ".pyi"], + async enabled(context) { + if (await ruff.enabled(context)) return false + const uv = which("uv") + if (uv == null) return false + const output = await Process.run([uv, "format", "--help"], { nothrow: true }) + if (output.code === 0) return [uv, "format", "--", "$FILE"] + return false + }, +} + +export const rubocop: Info = { + name: "rubocop", + extensions: [".rb", ".rake", ".gemspec", ".ru"], + async enabled() { + const match = which("rubocop") + if (!match) return false + return [match, "--autocorrect", "$FILE"] + }, +} + +export const standardrb: Info = { + name: "standardrb", + extensions: [".rb", ".rake", ".gemspec", ".ru"], + async enabled() { + const match = which("standardrb") + if (!match) return false + return [match, "--fix", "$FILE"] + }, +} + +export const htmlbeautifier: Info = { + name: "htmlbeautifier", + extensions: [".erb", ".html.erb"], + async enabled() { + const match = which("htmlbeautifier") + if (!match) return false + return [match, "$FILE"] + }, +} + +export const dart: Info = { + name: "dart", + extensions: [".dart"], + async enabled() { + const match = which("dart") + if (!match) return false + return [match, "format", "$FILE"] + }, +} + +export const ocamlformat: Info = { + name: "ocamlformat", + extensions: [".ml", ".mli"], + async enabled(context) { + if (!which("ocamlformat")) return false + const items = await Filesystem.findUp(".ocamlformat", context.directory, context.worktree) + if (items.length > 0) return ["ocamlformat", "-i", "$FILE"] + return false + }, +} + +export const terraform: Info = { + name: "terraform", + extensions: [".tf", ".tfvars"], + async enabled() { + const match = which("terraform") + if (!match) return false + return [match, "fmt", "$FILE"] + }, +} + +export const latexindent: Info = { + name: "latexindent", + extensions: [".tex"], + async enabled() { + const match = which("latexindent") + if (!match) return false + return [match, "-w", "-s", "$FILE"] + }, +} + +export const gleam: Info = { + name: "gleam", + extensions: [".gleam"], + async enabled() { + const match = which("gleam") + if (!match) return false + return [match, "format", "$FILE"] + }, +} + +export const shfmt: Info = { + name: "shfmt", + extensions: [".sh", ".bash"], + async enabled() { + const match = which("shfmt") + if (!match) return false + return [match, "-w", "$FILE"] + }, +} + +export const nixfmt: Info = { + name: "nixfmt", + extensions: [".nix"], + async enabled() { + const match = which("nixfmt") + if (!match) return false + return [match, "$FILE"] + }, +} + +export const rustfmt: Info = { + name: "rustfmt", + extensions: [".rs"], + async enabled() { + const match = which("rustfmt") + if (!match) return false + return [match, "$FILE"] + }, +} + +export const pint: Info = { + name: "pint", + extensions: [".php"], + async enabled(context) { + const items = await Filesystem.findUp("composer.json", context.directory, context.worktree) + for (const item of items) { + const json = await Filesystem.readJson<{ + require?: Record + "require-dev"?: Record + }>(item) + if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) return ["./vendor/bin/pint", "$FILE"] + } + return false + }, +} + +export const ormolu: Info = { + name: "ormolu", + extensions: [".hs"], + async enabled() { + const match = which("ormolu") + if (!match) return false + return [match, "-i", "$FILE"] + }, +} + +export const cljfmt: Info = { + name: "cljfmt", + extensions: [".clj", ".cljs", ".cljc", ".edn"], + async enabled() { + const match = which("cljfmt") + if (!match) return false + return [match, "fix", "--quiet", "$FILE"] + }, +} + +export const dfmt: Info = { + name: "dfmt", + extensions: [".d"], + async enabled() { + const match = which("dfmt") + if (!match) return false + return [match, "-i", "$FILE"] + }, +} diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4288f83efce96805b4c9958b00ab798da96d18d3 --- /dev/null +++ b/packages/opencode/src/format/index.ts @@ -0,0 +1,203 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer, Context, Schema } from "effect" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { ChildProcess } from "effect/unstable/process" +import { AppProcess } from "@opencode-ai/core/process" +import { InstanceState } from "@/effect/instance-state" +import path from "path" +import { mergeDeep } from "remeda" +import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { errorMessage } from "@/util/error" +import * as Formatter from "./formatter" + +export const Status = Schema.Struct({ + name: Schema.String, + extensions: Schema.Array(Schema.String), + enabled: Schema.Boolean, +}).annotate({ identifier: "FormatterStatus" }) +export type Status = Schema.Schema.Type + +export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly file: (filepath: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Format") {} + +export const use = serviceUse(Service) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const appProcess = yield* AppProcess.Service + const flags = yield* RuntimeFlags.Service + + const state = yield* InstanceState.make( + Effect.fn("Format.state")(function* (ctx) { + const commands: Record = {} + const formatters: Record = {} + + async function getCommand(item: Formatter.Info) { + let cmd = commands[item.name] + if (cmd === false || cmd === undefined) { + cmd = await item.enabled({ ...ctx, experimentalOxfmt: flags.experimentalOxfmt }) + commands[item.name] = cmd + } + return cmd + } + + async function isEnabled(item: Formatter.Info) { + const cmd = await getCommand(item) + return cmd !== false + } + + async function getFormatter(ext: string) { + const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext)) + const checks = await Promise.all( + matching.map(async (item) => { + const cmd = await getCommand(item) + return { + item, + cmd, + } + }), + ) + return checks + .filter((x): x is { item: Formatter.Info; cmd: string[] } => x.cmd !== false) + .map((x) => ({ item: x.item, cmd: x.cmd })) + } + + function formatFile(filepath: string) { + return Effect.gen(function* () { + yield* Effect.logInfo("formatting", { file: filepath }) + const formatters = yield* Effect.promise(() => getFormatter(path.extname(filepath))) + + if (!formatters.length) return false + + for (const { item, cmd } of formatters) { + yield* Effect.logInfo("running", { command: cmd }) + const replaced = cmd.map((x) => x.replace("$FILE", filepath)) + const dir = yield* InstanceState.directory + const result = yield* appProcess + .run( + ChildProcess.make(replaced[0]!, replaced.slice(1), { + cwd: dir, + env: item.environment, + extendEnv: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ) + .pipe( + Effect.catch((error) => + Effect.logError("failed to format file", { + error: "spawn failed", + command: cmd, + ...item.environment, + file: filepath, + cause: errorMessage(error.cause ?? error), + }).pipe(Effect.as(undefined)), + ), + ) + if (result && result.exitCode !== 0) { + yield* Effect.logError("failed", { + command: cmd, + ...item.environment, + }) + } + } + + return true + }) + } + + const cfg = yield* config.get() + + if (!cfg.formatter) { + yield* Effect.logInfo("all formatters are disabled") + yield* Effect.logInfo("init") + return { + formatters, + isEnabled, + formatFile, + } + } + + for (const item of Object.values(Formatter)) { + formatters[item.name] = item + } + + if (cfg.formatter !== true) { + for (const [name, item] of Object.entries(cfg.formatter)) { + const builtIn = Formatter[name as keyof typeof Formatter] + + // Ruff and uv are both the same formatter, so disabling either should disable both. + if (["ruff", "uv"].includes(name) && (cfg.formatter.ruff?.disabled || cfg.formatter.uv?.disabled)) { + // TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here. + delete formatters.ruff + delete formatters.uv + continue + } + if (item.disabled) { + delete formatters[name] + continue + } + const info = mergeDeep(builtIn ?? { extensions: [] }, item) + + formatters[name] = { + ...info, + name, + extensions: info.extensions ?? [], + enabled: builtIn && !info.command ? builtIn.enabled : async (_context) => info.command ?? false, + } + } + } + + yield* Effect.logInfo("init") + + return { + formatters, + isEnabled, + formatFile, + } + }), + ) + + const init = Effect.fn("Format.init")(function* () { + yield* InstanceState.get(state) + }) + + const status = Effect.fn("Format.status")(function* () { + const { formatters, isEnabled } = yield* InstanceState.get(state) + const result: Status[] = [] + for (const formatter of Object.values(formatters)) { + const isOn = yield* Effect.promise(() => isEnabled(formatter)) + result.push({ + name: formatter.name, + extensions: formatter.extensions, + enabled: isOn, + }) + } + return result + }) + + const file = Effect.fn("Format.file")(function* (filepath: string) { + const { formatFile } = yield* InstanceState.get(state) + return yield* formatFile(filepath) + }) + + return Service.of({ init, status, file }) + }), +) + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, AppProcess.node, RuntimeFlags.node], +}) + +export * as Format from "." diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..968e394a959fbfaabf4823eeef88875ff00563c6 --- /dev/null +++ b/packages/opencode/src/git/index.ts @@ -0,0 +1,348 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppProcess } from "@opencode-ai/core/process" +import { Effect, Layer, Context, Stream } from "effect" +import { ChildProcess } from "effect/unstable/process" + +const cfg = [ + "--no-optional-locks", + "-c", + "core.autocrlf=false", + "-c", + "core.fsmonitor=false", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + "-c", + "core.quotepath=false", +] as const + +const out = (result: { text(): string }) => result.text().trim() +const nuls = (text: string) => text.split("\0").filter(Boolean) +const fail = (err: unknown) => + ({ + exitCode: 1, + text: () => "", + stdout: Buffer.alloc(0), + stderr: Buffer.from(err instanceof Error ? err.message : String(err)), + truncated: false, + }) satisfies Result + +export type Kind = "added" | "deleted" | "modified" + +export type Base = { + readonly name: string + readonly ref: string +} + +export type Item = { + readonly file: string + readonly code: string + readonly status: Kind +} + +export type Stat = { + readonly file: string + readonly additions: number + readonly deletions: number +} + +export type Patch = { + readonly text: string + readonly truncated: boolean +} + +export interface PatchOptions { + readonly context?: number + readonly maxOutputBytes?: number +} + +export interface Result { + readonly exitCode: number + readonly text: () => string + readonly stdout: Buffer + readonly stderr: Buffer + readonly truncated: boolean +} + +export interface Options { + readonly cwd: string + readonly env?: Record + readonly maxOutputBytes?: number + readonly stdin?: ChildProcess.CommandInput +} + +export interface Interface { + readonly run: (args: string[], opts: Options) => Effect.Effect + readonly branch: (cwd: string) => Effect.Effect + readonly prefix: (cwd: string) => Effect.Effect + readonly defaultBranch: (cwd: string) => Effect.Effect + readonly hasHead: (cwd: string) => Effect.Effect + readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect + readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect + readonly status: (cwd: string) => Effect.Effect + readonly diff: (cwd: string, ref: string) => Effect.Effect + readonly stats: (cwd: string, ref: string) => Effect.Effect + readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect + readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect + readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect + readonly statUntracked: (cwd: string, file: string) => Effect.Effect + readonly applyPatch: (cwd: string, patch: string) => Effect.Effect +} + +const kind = (code: string): Kind => { + if (code === "??") return "added" + if (code.includes("U")) return "modified" + if (code.includes("A") && !code.includes("D")) return "added" + if (code.includes("D") && !code.includes("A")) return "deleted" + return "modified" +} + +export class Service extends Context.Service()("@opencode/Git") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const appProcess = yield* AppProcess.Service + const encoder = new TextEncoder() + const stdin = (text: string) => Stream.make(encoder.encode(text)) + + const run = Effect.fn("Git.run")( + function* (args: string[], opts: Options) { + const result = yield* appProcess.run( + ChildProcess.make("git", [...cfg, ...args], { + cwd: opts.cwd, + env: opts.env, + extendEnv: true, + stdin: opts.stdin ?? "ignore", + stdout: "pipe", + stderr: "pipe", + }), + { maxOutputBytes: opts.maxOutputBytes }, + ) + return { + exitCode: result.exitCode, + text: () => result.stdout.toString("utf8"), + stdout: result.stdout, + stderr: result.stderr, + truncated: result.stdoutTruncated || result.stderrTruncated, + } satisfies Result + }, + Effect.catch((err) => Effect.succeed(fail(err))), + ) + + const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) { + return (yield* run(args, opts)).text() + }) + + const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) { + return (yield* text(args, opts)) + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean) + }) + + const refs = Effect.fnUntraced(function* (cwd: string) { + return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd }) + }) + + const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) { + const result = yield* run(["config", "init.defaultBranch"], { cwd }) + const name = out(result) + if (!name || !list.includes(name)) return + return { name, ref: name } satisfies Base + }) + + const primary = Effect.fnUntraced(function* (cwd: string) { + const list = yield* lines(["remote"], { cwd }) + if (list.includes("origin")) return "origin" + if (list.length === 1) return list[0] + if (list.includes("upstream")) return "upstream" + return list[0] + }) + + const branch = Effect.fn("Git.branch")(function* (cwd: string) { + const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd }) + if (result.exitCode !== 0) return + const text = out(result) + return text || undefined + }) + + const prefix = Effect.fn("Git.prefix")(function* (cwd: string) { + const result = yield* run(["rev-parse", "--show-prefix"], { cwd }) + if (result.exitCode !== 0) return "" + return out(result) + }) + + const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) { + const remote = yield* primary(cwd) + if (remote) { + const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd }) + if (head.exitCode === 0) { + const ref = out(head).replace(/^refs\/remotes\//, "") + const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : "" + if (name) return { name, ref } satisfies Base + } + } + + const list = yield* refs(cwd) + const next = yield* configured(cwd, list) + if (next) return next + if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base + if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base + }) + + const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) { + const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd }) + return result.exitCode === 0 + }) + + const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") { + const result = yield* run(["merge-base", base, head], { cwd }) + if (result.exitCode !== 0) return + const text = out(result) + return text || undefined + }) + + const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") { + const target = prefix ? `${prefix}${file}` : file + const result = yield* run(["show", `${ref}:${target}`], { cwd }) + if (result.exitCode !== 0) return "" + if (result.stdout.includes(0)) return "" + return result.text() + }) + + const status = Effect.fn("Git.status")(function* (cwd: string) { + return nuls( + yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], { + cwd, + }), + ).flatMap((item) => { + const file = item.slice(3) + if (!file) return [] + const code = item.slice(0, 2) + return [{ file, code, status: kind(code) } satisfies Item] + }) + }) + + const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) { + const list = nuls( + yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }), + ) + return list.flatMap((code, idx) => { + if (idx % 2 !== 0) return [] + const file = list[idx + 1] + if (!code || !file) return [] + return [{ file, code, status: kind(code) } satisfies Item] + }) + }) + + const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) { + return nuls( + yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }), + ).flatMap((item) => { + const a = item.indexOf("\t") + const b = item.indexOf("\t", a + 1) + if (a === -1 || b === -1) return [] + const file = item.slice(b + 1) + if (!file) return [] + const adds = item.slice(0, a) + const dels = item.slice(a + 1, b) + const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10) + const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10) + return [ + { + file, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Stat, + ] + }) + }) + + const patch = Effect.fn("Git.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchAll = Effect.fn("Git.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchUntracked = Effect.fn("Git.patchUntracked")(function* ( + cwd: string, + file: string, + options?: PatchOptions, + ) { + const result = yield* run( + [ + "diff", + "--no-index", + "--patch", + "--no-ext-diff", + "--no-renames", + `--unified=${options?.context ?? 3}`, + "--", + "/dev/null", + file, + ], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const statUntracked = Effect.fn("Git.statUntracked")(function* (cwd: string, file: string) { + const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], { + cwd, + maxOutputBytes: 4096, + }) + + if (result.truncated) return + const text = result.text() + + const parts = text.split("\t") + if (parts.length < 2) return + + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10) + const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10) + return { + file, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Stat + }) + + const applyPatch = Effect.fn("Git.applyPatch")(function* (cwd: string, patch: string) { + return yield* run(["apply", "-"], { cwd, stdin: stdin(patch) }) + }) + + return Service.of({ + run, + branch, + prefix, + defaultBranch, + hasHead, + mergeBase, + show, + status, + diff, + stats, + patch, + patchAll, + patchUntracked, + statUntracked, + applyPatch, + }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [AppProcess.node] }) + +export * as Git from "." diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts new file mode 100644 index 0000000000000000000000000000000000000000..847a5c03292457fb91522121e791ded0d345bee9 --- /dev/null +++ b/packages/opencode/src/id/id.ts @@ -0,0 +1,80 @@ +import { randomBytes } from "crypto" + +const prefixes = { + job: "job", + event: "evt", + session: "ses", + message: "msg", + permission: "per", + question: "que", + part: "prt", + pty: "pty", + tool: "tool", + workspace: "wrk", +} as const + +const LENGTH = 26 + +// State for monotonic ID generation +let lastTimestamp = 0 +let counter = 0 + +export function ascending(prefix: keyof typeof prefixes, given?: string) { + return generateID(prefix, "ascending", given) +} + +export function descending(prefix: keyof typeof prefixes, given?: string) { + return generateID(prefix, "descending", given) +} + +function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string { + if (!given) { + return create(prefixes[prefix], direction) + } + + if (!given.startsWith(prefixes[prefix])) { + throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`) + } + return given +} + +function randomBase62(length: number): string { + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + let result = "" + const bytes = randomBytes(length) + for (let i = 0; i < length; i++) { + result += chars[bytes[i] % 62] + } + return result +} + +export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string { + const currentTimestamp = timestamp ?? Date.now() + + if (currentTimestamp !== lastTimestamp) { + lastTimestamp = currentTimestamp + counter = 0 + } + counter++ + + let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) + + now = direction === "descending" ? ~now : now + + const timeBytes = Buffer.alloc(6) + for (let i = 0; i < 6; i++) { + timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) + } + + return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12) +} + +/** Extract timestamp from an ascending ID. Does not work with descending IDs. */ +export function timestamp(id: string): number { + const prefix = id.split("_")[0] + const hex = id.slice(prefix.length + 1, prefix.length + 13) + const encoded = BigInt("0x" + hex) + return Number(encoded / BigInt(0x1000)) +} + +export * as Identifier from "./id" diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..31b3add14b1d39e1c7fdde91161ca60922d4505f --- /dev/null +++ b/packages/opencode/src/ide/index.ts @@ -0,0 +1,54 @@ +import { Schema } from "effect" +import { NamedError } from "@opencode-ai/core/util/error" +import { Process } from "@/util/process" +import { IdeEvent } from "@opencode-ai/schema/ide-event" + +const SUPPORTED_IDES = [ + { name: "Windsurf" as const, cmd: "windsurf" }, + { name: "Visual Studio Code - Insiders" as const, cmd: "code-insiders" }, + { name: "Visual Studio Code" as const, cmd: "code" }, + { name: "Cursor" as const, cmd: "cursor" }, + { name: "VSCodium" as const, cmd: "codium" }, +] + +export const Event = IdeEvent + +export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {}) + +export const InstallFailedError = NamedError.create("InstallFailedError", { + stderr: Schema.String, +}) + +export function ide() { + if (process.env["TERM_PROGRAM"] === "vscode") { + const v = process.env["GIT_ASKPASS"] + for (const ide of SUPPORTED_IDES) { + if (v?.includes(ide.name)) return ide.name + } + } + return "unknown" +} + +export function alreadyInstalled() { + return process.env["OPENCODE_CALLER"] === "vscode" || process.env["OPENCODE_CALLER"] === "vscode-insiders" +} + +export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) { + const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd + if (!cmd) throw new Error(`Unknown IDE: ${ide}`) + + const p = await Process.run([cmd, "--install-extension", "sst-dev.opencode"], { + nothrow: true, + }) + const stdout = p.stdout.toString() + const stderr = p.stderr.toString() + + if (p.code !== 0) { + throw new InstallFailedError({ stderr }) + } + if (stdout.includes("already installed")) { + throw new AlreadyInstalledError({}) + } +} + +export * as Ide from "." diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts new file mode 100644 index 0000000000000000000000000000000000000000..744bd3fc959164e3a9328e5178b9219a9634dc4a --- /dev/null +++ b/packages/opencode/src/image/image.ts @@ -0,0 +1,172 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Config } from "@/config/config" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import type { MessageV2 } from "@/session/message-v2" +import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" } +import { Context, Effect, Layer, Schema } from "effect" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const MAX_BASE64_BYTES = 5 * 1024 * 1024 +const MAX_WIDTH = 2000 +const MAX_HEIGHT = 2000 +const AUTO_RESIZE = true +const JPEG_QUALITIES = [80, 85, 70, 55, 40] +export class ResizerUnavailableError extends Schema.TaggedErrorClass()( + "ImageResizerUnavailableError", + {}, +) { + override get message() { + return "Image resizer is unavailable" + } +} + +export class InvalidDataUrlError extends Schema.TaggedErrorClass()("ImageInvalidDataUrlError", { + url: Schema.String, +}) { + override get message() { + return "Image URL must be a base64 data URL" + } +} + +export class DecodeError extends Schema.TaggedErrorClass()("ImageDecodeError", {}) { + override get message() { + return "Image could not be decoded" + } +} + +export class SizeError extends Schema.TaggedErrorClass()("ImageSizeError", { + bytes: Schema.Number, + max: Schema.Number, + width: Schema.Number, + height: Schema.Number, + max_width: Schema.Number, + max_height: Schema.Number, +}) { + override get message() { + return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes` + } +} + +export type Error = ResizerUnavailableError | InvalidDataUrlError | DecodeError | SizeError + +export interface Interface { + readonly normalize: (input: SessionV1.FilePart) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Image") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const loadPhoton = yield* Effect.cached( + Effect.sync(() => { + // Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path. + ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = + path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url)) + }).pipe( + Effect.andThen(() => Effect.tryPromise(() => import("@silvia-odwyer/photon-node"))), + Effect.tapError((error) => Effect.logWarning("failed to load photon", { error })), + Effect.mapError(() => new ResizerUnavailableError()), + ), + ) + + const normalize = Effect.fn("Image.normalize")(function* (input: SessionV1.FilePart) { + const image = (yield* config.get()).attachment?.image + const info = { + autoResize: image?.auto_resize ?? AUTO_RESIZE, + maxWidth: image?.max_width ?? MAX_WIDTH, + maxHeight: image?.max_height ?? MAX_HEIGHT, + maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES, + } + if (!input.url.startsWith("data:") || !input.url.includes(";base64,")) + return yield* new InvalidDataUrlError({ url: input.url }) + + const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length) + const bytes = Buffer.byteLength(base64, "utf8") + + const photon = yield* loadPhoton + + const decoded = yield* Effect.try({ + try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")), + catch: () => new DecodeError(), + }).pipe(Effect.tapError((error) => Effect.logWarning("failed to decode image", { error }))) + + try { + const originalWidth = decoded.get_width() + const originalHeight = decoded.get_height() + if (originalWidth <= info.maxWidth && originalHeight <= info.maxHeight && bytes <= info.maxBase64Bytes) + return input + if (!info.autoResize) + return yield* new SizeError({ + bytes, + max: info.maxBase64Bytes, + width: originalWidth, + height: originalHeight, + max_width: info.maxWidth, + max_height: info.maxHeight, + }) + + const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight) + for (const size of Array.from({ length: 32 }).reduce>((acc) => { + const previous = acc.at(-1) ?? { + width: Math.max(1, Math.round(originalWidth * scale)), + height: Math.max(1, Math.round(originalHeight * scale)), + } + const next = + acc.length === 0 + ? previous + : { + width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)), + height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)), + } + return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next] + }, [])) { + const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3) + const candidate = [ + { data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" }, + ...JPEG_QUALITIES.map((quality) => ({ + data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"), + mime: "image/jpeg", + })), + ] + .map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") })) + .find((item) => item.bytes <= info.maxBase64Bytes) + resized.free() + + if (candidate) { + yield* Effect.logInfo("using resized image", { + from_mime: input.mime, + to_mime: candidate.mime, + from: `${originalWidth}x${originalHeight}`, + to: `${size.width}x${size.height}`, + }) + return { + ...input, + mime: candidate.mime, + url: `data:${candidate.mime};base64,${candidate.data}`, + } + } + } + + return yield* new SizeError({ + bytes, + max: info.maxBase64Bytes, + width: originalWidth, + height: originalHeight, + max_width: info.maxWidth, + max_height: info.maxHeight, + }) + } finally { + decoded.free() + } + }) + + return Service.of({ normalize }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node] }) + +export * as Image from "./image" diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..13540a73a36fcb27f591011d9147c60073055b45 --- /dev/null +++ b/packages/opencode/src/index.ts @@ -0,0 +1,142 @@ +import yargs from "yargs" +import { hideBin } from "yargs/helpers" +import { RunCommand } from "./cli/cmd/run" +import { GenerateCommand } from "./cli/cmd/generate" +import { ConsoleCommand } from "./cli/cmd/account" +import { ProvidersCommand } from "./cli/cmd/providers" +import { AgentCommand } from "./cli/cmd/agent" +import { UpgradeCommand } from "./cli/cmd/upgrade" +import { UninstallCommand } from "./cli/cmd/uninstall" +import { ModelsCommand } from "./cli/cmd/models" +import { UI } from "./cli/ui" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { FormatError } from "./cli/error" +import { ServeCommand } from "./cli/cmd/serve" +import { DebugCommand } from "./cli/cmd/debug" +import { StatsCommand } from "./cli/cmd/stats" +import { McpCommand } from "./cli/cmd/mcp" +import { GithubCommand } from "./cli/cmd/github" +import { ExportCommand } from "./cli/cmd/export" +import { ImportCommand } from "./cli/cmd/import" +import { AttachCommand } from "./cli/cmd/attach" +import { TuiThreadCommand } from "./cli/cmd/tui" +import { AcpCommand } from "./cli/cmd/acp" +import { EOL } from "os" +import { WebCommand } from "./cli/cmd/web" +import { PrCommand } from "./cli/cmd/pr" +import { SessionCommand } from "./cli/cmd/session" +import { DbCommand } from "./cli/cmd/db" +import { errorMessage } from "./util/error" +import { PluginCommand } from "./cli/cmd/plug" +import { Heap } from "./cli/heap" + +const args = hideBin(process.argv) + +function show(out: string) { + const text = out.trimStart() + if (!text.startsWith("opencode ")) { + process.stderr.write(UI.logo() + EOL + EOL) + process.stderr.write(text + EOL) + return + } + process.stderr.write(out) +} + +const cli = yargs(args) + .parserConfiguration({ "populate--": true }) + .scriptName("opencode") + .wrap(100) + .help("help", "show help") + .alias("help", "h") + .version("version", "show version number", InstallationVersion) + .alias("version", "v") + .option("print-logs", { + describe: "print logs to stderr", + type: "boolean", + }) + .option("log-level", { + describe: "log level", + type: "string", + choices: ["DEBUG", "INFO", "WARN", "ERROR"], + }) + .option("pure", { + describe: "run without external plugins", + type: "boolean", + }) + .middleware(async (opts) => { + if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1" + if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel + if (opts.pure) { + process.env.OPENCODE_PURE = "1" + } + + Heap.start() + + process.env.AGENT = "1" + process.env.OPENCODE = "1" + process.env.OPENCODE_PID = String(process.pid) + }) + .usage("") + .completion("completion", "generate shell completion script") + .command(AcpCommand) + .command(McpCommand) + .command(TuiThreadCommand) + .command(AttachCommand) + .command(RunCommand) + .command(GenerateCommand) + .command(DebugCommand) + .command(ConsoleCommand) + .command(ProvidersCommand) + .command(AgentCommand) + .command(UpgradeCommand) + .command(UninstallCommand) + .command(ServeCommand) + .command(WebCommand) + .command(ModelsCommand) + .command(StatsCommand) + .command(ExportCommand) + .command(ImportCommand) + .command(GithubCommand) + .command(PrCommand) + .command(SessionCommand) + .command(PluginCommand) + .command(DbCommand) + .fail((msg, err) => { + if ( + msg?.startsWith("Unknown argument") || + msg?.startsWith("Not enough non-option arguments") || + msg?.startsWith("Invalid values:") + ) { + if (err) throw err + cli.showHelp(show) + } + if (err) throw err + process.exit(1) + }) + .strict() + +try { + if (args.includes("-h") || args.includes("--help")) { + await cli.parse(args, (err: Error | undefined, _argv: unknown, out: string) => { + if (err) throw err + if (!out) return + show(out) + }) + } else { + await cli.parse() + } +} catch (e) { + const formatted = FormatError(e) + if (formatted) UI.error(formatted) + if (formatted === undefined) { + UI.error("Unexpected error" + EOL) + process.stderr.write(errorMessage(e) + EOL) + } + process.exitCode = 1 +} finally { + // Some subprocesses don't react properly to SIGTERM and similar signals. + // Most notably, some docker-container-based MCP servers don't handle such signals unless + // run using `docker run --init`. + // Explicitly exit to avoid any hanging subprocesses. + process.exit() +} diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4300220255b3ba717eeb229afd26f5e745c330ed --- /dev/null +++ b/packages/opencode/src/installation/index.ts @@ -0,0 +1,336 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { Effect, Layer, Schema, Context, Stream } from "effect" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { withTransientReadRetry } from "@/util/effect-http-client" +import { errorMessage } from "@/util/error" +import { ChildProcess } from "effect/unstable/process" +import { AppProcess } from "@opencode-ai/core/process" +import path from "path" +import { makeRuntime } from "@opencode-ai/core/effect/runtime" +import semver from "semver" +import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" +import { NpmConfig } from "@opencode-ai/core/npm-config" +import { InstallationEvent } from "@opencode-ai/schema/installation-event" + +export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" + +export type ReleaseType = "patch" | "minor" | "major" + +export const Event = InstallationEvent + +export function getReleaseType(current: string, latest: string): ReleaseType { + const currMajor = semver.major(current) + const currMinor = semver.minor(current) + const newMajor = semver.major(latest) + const newMinor = semver.minor(latest) + + if (newMajor > currMajor) return "major" + if (newMinor > currMinor) return "minor" + return "patch" +} + +export const Info = Schema.Struct({ + version: Schema.String, + latest: Schema.String, +}).annotate({ identifier: "InstallationInfo" }) +export type Info = Schema.Schema.Type + +export function userAgent(client = "cli") { + return `opencode/${InstallationChannel}/${InstallationVersion}/${client}` +} + +export const USER_AGENT = userAgent() + +export function isPreview() { + return InstallationChannel !== "latest" +} + +export function isLocal() { + return InstallationChannel === "local" +} + +export class UpgradeFailedError extends Schema.TaggedErrorClass()("UpgradeFailedError", { + stderr: Schema.String, +}) { + override get message() { + return this.stderr + } +} + +// Response schemas for external version APIs +const GitHubRelease = Schema.Struct({ tag_name: Schema.String }) +const NpmPackage = Schema.Struct({ version: Schema.String }) +const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) }) +const BrewInfoV2 = Schema.Struct({ + formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })), +}) +const ChocoPackage = Schema.Struct({ + d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }), +}) +const ScoopManifest = NpmPackage + +export interface Interface { + readonly info: () => Effect.Effect + readonly method: () => Effect.Effect + readonly latest: (method?: Method) => Effect.Effect + readonly upgrade: (method: Method, target: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Installation") {} + +export const use = serviceUse(Service) + +const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http)) + const appProcess = yield* AppProcess.Service + + const text = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + const result = yield* appProcess.run( + ChildProcess.make(cmd[0], cmd.slice(1), { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }), + ) + return result.stdout.toString("utf8") + }, + Effect.catch(() => Effect.succeed("")), + ) + + const run = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + const result = yield* appProcess.run( + ChildProcess.make(cmd[0], cmd.slice(1), { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }), + ) + return { + code: result.exitCode, + stdout: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + } + }, + Effect.catch((err) => Effect.succeed({ code: 1, stdout: "", stderr: errorMessage(err) })), + ) + + const getBrewFormula = Effect.fnUntraced(function* () { + const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) + if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" + const coreFormula = yield* text(["brew", "list", "--formula", "opencode"]) + if (coreFormula.includes("opencode")) return "opencode" + return "opencode" + }) + + const upgradeFailure = (method: Method, result?: { code: number; stdout: string; stderr: string }) => { + if (method === "choco") return "not running from an elevated command shell" + if (result) return `Upgrade failed for ${method} (exit code ${result.code}).` + return `Upgrade failed for ${method}.` + } + + const upgradeScriptShell = Effect.fnUntraced(function* () { + const bashVersion = yield* text(["bash", "--version"]) + if (bashVersion) return "bash" + return "sh" + }) + + const upgradeCurl = Effect.fnUntraced( + function* (target: string) { + const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install")) + const body = yield* response.text + const bodyBytes = new TextEncoder().encode(body) + const shell = yield* upgradeScriptShell() + const result = yield* appProcess.run( + ChildProcess.make(shell, [], { + stdin: Stream.make(bodyBytes), + env: { VERSION: target }, + extendEnv: true, + }), + ) + return { + code: result.exitCode, + stdout: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + } + }, + Effect.mapError(() => new UpgradeFailedError({ stderr: upgradeFailure("curl") })), + ) + + const result: Interface = { + info: Effect.fn("Installation.info")(function* () { + return { + version: InstallationVersion, + latest: yield* result.latest(), + } + }), + method: Effect.fn("Installation.method")(function* () { + if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method + if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method + const exec = process.execPath.toLowerCase() + + const checks: Array<{ name: Method; command: () => Effect.Effect }> = [ + { name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) }, + { name: "yarn", command: () => text(["yarn", "global", "list"]) }, + { name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) }, + { name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) }, + { name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) }, + { name: "scoop", command: () => text(["scoop", "list", "opencode"]) }, + { name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) }, + ] + + checks.sort((a, b) => { + const aMatches = exec.includes(a.name) + const bMatches = exec.includes(b.name) + if (aMatches && !bMatches) return -1 + if (!aMatches && bMatches) return 1 + return 0 + }) + + for (const check of checks) { + const output = yield* check.command() + const installedName = + check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai" + if (output.includes(installedName)) { + return check.name + } + } + + return "unknown" as Method + }), + latest: Effect.fn("Installation.latest")(function* (installMethod?: Method) { + const detectedMethod = installMethod || (yield* result.method()) + + if (detectedMethod === "brew") { + const formula = yield* getBrewFormula() + if (formula.includes("/")) { + const infoJson = yield* text(["brew", "info", "--json=v2", formula]) + const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson) + return info.formulae[0].versions.stable + } + const response = yield* httpOk.execute( + HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe( + HttpClientRequest.acceptJson, + ), + ) + const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response) + return data.versions.stable + } + + if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { + const response = yield* httpOk.execute( + HttpClientRequest.get( + `${yield* NpmConfig.registry(process.cwd())}/opencode-ai/${InstallationChannel}`, + ).pipe(HttpClientRequest.acceptJson), + ) + const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response) + return data.version + } + + if (detectedMethod === "choco") { + const response = yield* httpOk.execute( + HttpClientRequest.get( + "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version", + ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })), + ) + const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response) + return data.d.results[0].Version + } + + if (detectedMethod === "scoop") { + const response = yield* httpOk.execute( + HttpClientRequest.get( + "https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", + ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })), + ) + const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response) + return data.version + } + + const response = yield* httpOk.execute( + HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe( + HttpClientRequest.acceptJson, + ), + ) + const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response) + return data.tag_name.replace(/^v/, "") + }, Effect.orDie), + upgrade: Effect.fn("Installation.upgrade")(function* (m: Method, target: string) { + let upgradeResult: { code: number; stdout: string; stderr: string } | undefined + switch (m) { + case "curl": + upgradeResult = yield* upgradeCurl(target) + break + case "npm": + upgradeResult = yield* run(["npm", "install", "-g", `opencode-ai@${target}`]) + break + case "pnpm": + upgradeResult = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`]) + break + case "bun": + upgradeResult = yield* run(["bun", "install", "-g", `opencode-ai@${target}`]) + break + case "brew": { + const formula = yield* getBrewFormula() + const env = { HOMEBREW_NO_AUTO_UPDATE: "1" } + if (formula.includes("/")) { + const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env }) + if (tap.code !== 0) { + upgradeResult = tap + break + } + const repo = yield* text(["brew", "--repo", "anomalyco/tap"]) + const dir = repo.trim() + if (dir) { + const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env }) + if (pull.code !== 0) { + upgradeResult = pull + break + } + } + } + upgradeResult = yield* run(["brew", "upgrade", formula], { env }) + break + } + case "choco": + upgradeResult = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"]) + break + case "scoop": + upgradeResult = yield* run(["scoop", "install", `opencode@${target}`]) + break + default: + return yield* new UpgradeFailedError({ stderr: `Unknown installation method: ${m}` }) + } + if (!upgradeResult || upgradeResult.code !== 0) { + return yield* new UpgradeFailedError({ stderr: upgradeFailure(m, upgradeResult) }) + } + yield* Effect.logInfo("upgraded", { + method: m, + target, + stdout: upgradeResult.stdout, + stderr: upgradeResult.stderr, + }) + yield* text([process.execPath, "--version"]) + }), + } + + return Service.of(result) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [httpClient, AppProcess.node] }) + +const { runPromise } = makeRuntime(Service, AppNodeBuilder.build(node)) + +export const latest = (...args: Parameters) => runPromise((s) => s.latest(...args)) +export const method = () => runPromise((s) => s.method()) +export const upgrade = (...args: Parameters) => runPromise((s) => s.upgrade(...args)) + +export * as Installation from "." diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..08d8a53d9be0763f0eca592a85b35140815963ae --- /dev/null +++ b/packages/opencode/src/lsp/client.ts @@ -0,0 +1,650 @@ +import path from "path" +import { pathToFileURL, fileURLToPath } from "url" +import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node" +import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types" +import { Process } from "@/util/process" +import { LANGUAGE_EXTENSIONS } from "./language" +import { Effect, Schema } from "effect" +import type * as LSPServer from "./server" +import { withTimeout } from "../util/timeout" +import { Filesystem } from "@/util/filesystem" +import type { InstanceContext } from "@/project/instance-context" + +const DIAGNOSTICS_DEBOUNCE_MS = 150 +const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000 +const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000 +const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000 + +const INITIALIZE_TIMEOUT_MS = 45_000 + +// LSP spec constants +const FILE_CHANGE_CREATED = 1 +const FILE_CHANGE_CHANGED = 2 +const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2 + +export type Info = NonNullable>> + +export type Diagnostic = VSCodeDiagnostic + +export class InitializeError extends Schema.TaggedErrorClass()("LSPInitializeError", { + serverID: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +type DocumentDiagnosticReport = { + items?: Diagnostic[] + relatedDocuments?: Record +} + +type WorkspaceDiagnosticReport = { + items?: { + uri?: string + items?: Diagnostic[] + }[] +} + +type DiagnosticRequestResult = { + handled: boolean + matched: boolean + byFile: Map +} + +type CapabilityRegistration = { + id: string + method: string + registerOptions?: { + identifier?: string + workspaceDiagnostics?: boolean + } +} + +type ServerCapabilities = { + textDocumentSync?: + | number + | { + change?: number + } + diagnosticProvider?: unknown + [key: string]: unknown +} + +function getFilePath(uri: string) { + if (!uri.startsWith("file://")) return + return Filesystem.normalizePath(fileURLToPath(uri)) +} + +function getSyncKind(capabilities?: ServerCapabilities) { + if (!capabilities) return + const sync = capabilities.textDocumentSync + if (typeof sync === "number") return sync + return sync?.change +} + +function endPosition(text: string) { + const lines = text.split(/\r\n|\r|\n/) + return { + line: lines.length - 1, + character: lines.at(-1)?.length ?? 0, + } +} + +function dedupeDiagnostics(items: Diagnostic[]) { + const seen = new Set() + return items.filter((item) => { + const key = JSON.stringify({ + code: item.code, + severity: item.severity, + message: item.message, + source: item.source, + range: item.range, + }) + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function configurationValue(settings: unknown, section?: string) { + if (!section) return settings ?? null + const result = section.split(".").reduce((acc, key) => { + if (!acc || typeof acc !== "object" || !(key in acc)) return undefined + return (acc as Record)[key] + }, settings) + return result ?? null +} + +// TypeScript's built-in LSP pushes diagnostics aggressively on first open. +// We seed the push cache on the very first publish so waitForFreshPush can +// resolve immediately instead of waiting for a second debounced push. +function shouldSeedDiagnosticsOnFirstPush(serverID: string) { + return serverID === "typescript" +} + +export async function create(input: { + serverID: string + server: LSPServer.Handle + root: string + directory: string + instance: InstanceContext +}) { + const instance = input.instance + + const connection = createMessageConnection( + new StreamMessageReader(input.server.process.stdout as any), + new StreamMessageWriter(input.server.process.stdin as any), + ) + input.server.process.stderr?.resume() + // --- Connection state --- + + const pushDiagnostics = new Map() + const pullDiagnostics = new Map() + const published = new Map() + const diagnosticRegistrations = new Map() + const registrationListeners = new Set<() => void>() + const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>() + const mergedDiagnostics = (filePath: string) => + dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])]) + const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { + pushDiagnostics.set(filePath, next) + for (const listener of diagnosticListeners) listener({ path: filePath, serverID: input.serverID }) + } + const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { + pullDiagnostics.set(filePath, next) + } + const emitRegistrationChange = () => { + for (const listener of [...registrationListeners]) listener() + } + + // --- LSP connection handlers --- + + connection.onNotification("textDocument/publishDiagnostics", (params) => { + const filePath = getFilePath(params.uri) + if (!filePath) return + published.set(filePath, { + at: Date.now(), + version: typeof params.version === "number" ? params.version : undefined, + }) + if (shouldSeedDiagnosticsOnFirstPush(input.serverID) && !pushDiagnostics.has(filePath)) { + pushDiagnostics.set(filePath, params.diagnostics) + return + } + updatePushDiagnostics(filePath, params.diagnostics) + }) + connection.onRequest("window/workDoneProgress/create", (params) => { + return null + }) + connection.onRequest("workspace/configuration", async (params) => { + const items = (params as { items?: { section?: string }[] }).items ?? [] + return items.map((item) => configurationValue(input.server.initialization, item.section)) + }) + connection.onRequest("client/registerCapability", async (params) => { + const registrations = (params as { registrations?: CapabilityRegistration[] }).registrations ?? [] + let changed = false + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue + diagnosticRegistrations.set(registration.id, registration) + changed = true + } + if (changed) emitRegistrationChange() + }) + connection.onRequest("client/unregisterCapability", async (params) => { + const registrations = (params as { unregisterations?: { id: string; method: string }[] }).unregisterations ?? [] + let changed = false + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue + diagnosticRegistrations.delete(registration.id) + changed = true + } + if (changed) emitRegistrationChange() + }) + connection.onRequest("workspace/workspaceFolders", async () => [ + { + name: "workspace", + uri: pathToFileURL(input.root).href, + }, + ]) + connection.onRequest("workspace/diagnostic/refresh", async () => null) + connection.listen() + + // --- Initialize handshake --- + + const initialized = await withTimeout( + connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", { + rootUri: pathToFileURL(input.root).href, + processId: input.server.process.pid, + workspaceFolders: [ + { + name: "workspace", + uri: pathToFileURL(input.root).href, + }, + ], + initializationOptions: { + ...input.server.initialization, + }, + capabilities: { + window: { + workDoneProgress: true, + }, + workspace: { + configuration: true, + didChangeWatchedFiles: { + dynamicRegistration: true, + }, + diagnostics: { + refreshSupport: false, + }, + }, + textDocument: { + synchronization: { + didOpen: true, + didChange: true, + }, + diagnostic: { + dynamicRegistration: true, + relatedDocumentSupport: true, + }, + publishDiagnostics: { + versionSupport: false, + }, + }, + }, + }), + INITIALIZE_TIMEOUT_MS, + ).catch((err) => { + throw new InitializeError({ serverID: input.serverID, cause: err }) + }) + + const syncKind = getSyncKind(initialized.capabilities) + const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider) + + await connection.sendNotification("initialized", {}) + + if (input.server.initialization) { + await connection.sendNotification("workspace/didChangeConfiguration", { + settings: input.server.initialization, + }) + } + + const files: Record = {} + + // --- Diagnostic helpers --- + + const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => { + const handled = results.some((result) => result.handled) + const matched = results.some((result) => result.matched) + if (!handled) return { handled: false, matched: false } + + const merged = new Map() + for (const result of results) { + for (const [target, items] of result.byFile.entries()) { + const existing = merged.get(target) ?? [] + merged.set(target, existing.concat(items)) + } + } + + if (matched && !merged.has(filePath)) merged.set(filePath, []) + for (const [target, items] of merged.entries()) { + updatePullDiagnostics(target, dedupeDiagnostics(items)) + } + + return { handled, matched } + } + + async function requestDiagnosticReport(filePath: string, identifier?: string): Promise { + const report = await withTimeout( + connection.sendRequest("textDocument/diagnostic", { + ...(identifier ? { identifier } : {}), + textDocument: { + uri: pathToFileURL(filePath).href, + }, + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null) + if (!report) return { handled: false, matched: false, byFile: new Map() } + + const byFile = new Map() + const push = (target: string, items: Diagnostic[]) => { + const existing = byFile.get(target) ?? [] + byFile.set(target, existing.concat(items)) + } + + let handled = false + let matched = false + if (Array.isArray(report.items)) { + push(filePath, report.items) + handled = true + matched = true + } + for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) { + const relatedPath = getFilePath(uri) + if (!relatedPath || !Array.isArray(related.items)) continue + push(relatedPath, related.items) + handled = true + matched = matched || relatedPath === filePath + } + + return { handled, matched, byFile } + } + + async function requestWorkspaceDiagnosticReport( + filePath: string, + identifier?: string, + ): Promise { + const report = await withTimeout( + connection.sendRequest("workspace/diagnostic", { + ...(identifier ? { identifier } : {}), + previousResultIds: [], + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null) + if (!report) return { handled: false, matched: false, byFile: new Map() } + + const byFile = new Map() + let matched = false + for (const item of report.items ?? []) { + const relatedPath = item.uri ? getFilePath(item.uri) : undefined + if (!relatedPath || !Array.isArray(item.items)) continue + const existing = byFile.get(relatedPath) ?? [] + byFile.set(relatedPath, existing.concat(item.items)) + matched = matched || relatedPath === filePath + } + + return { handled: true, matched, byFile } + } + + function documentPullState() { + const documentRegistrations = [...diagnosticRegistrations.values()].filter( + (registration) => registration.registerOptions?.workspaceDiagnostics !== true, + ) + return { + documentIdentifiers: [ + ...new Set(documentRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? [])), + ], + supported: hasStaticPullDiagnostics || documentRegistrations.length > 0, + } + } + + function workspacePullState() { + const workspaceRegistrations = [...diagnosticRegistrations.values()].filter( + (registration) => registration.registerOptions?.workspaceDiagnostics === true, + ) + return { + workspaceIdentifiers: [ + ...new Set(workspaceRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? [])), + ], + supported: workspaceRegistrations.length > 0, + } + } + + const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) => + results.some((result) => (result.byFile.get(filePath)?.length ?? 0) > 0) + + async function requestDiagnostics( + filePath: string, + requests: Promise[], + done: (results: DiagnosticRequestResult[]) => boolean, + ) { + if (!requests.length) return { handled: false, matched: false } + + const results: DiagnosticRequestResult[] = [] + return new Promise<{ handled: boolean; matched: boolean }>((resolve) => { + let pending = requests.length + let resolved = false + const finish = (merged: { handled: boolean; matched: boolean }, force = false) => { + if (resolved) return + if (!force && !done(results)) return + resolved = true + resolve(merged) + } + + for (const request of requests) { + request.then((result) => { + results.push(result) + pending -= 1 + const merged = mergeResults(filePath, results) + finish(merged) + if (pending === 0) finish(merged, true) + }) + } + }) + } + + // LATENCY-CRITICAL: dispatch identifier pulls in parallel and unblock once one + // batch already produced diagnostics for the current file. Let slower pulls keep + // merging in the background; do not sequence identifier-by-identifier, and do + // not add a post-match settle/debounce delay. See PR #23771. + async function requestDocumentDiagnostics(filePath: string) { + const state = documentPullState() + if (!state.supported) return { handled: false, matched: false } + return requestDiagnostics( + filePath, + [ + requestDiagnosticReport(filePath), + ...state.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)), + ], + (results) => hasCurrentFileDiagnostics(filePath, results), + ) + } + + async function requestFullDiagnostics(filePath: string) { + const documentState = documentPullState() + const workspaceState = workspacePullState() + if (!documentState.supported && !workspaceState.supported) return { handled: false, matched: false } + return mergeResults( + filePath, + await Promise.all([ + ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []), + ...documentState.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)), + ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []), + ...workspaceState.workspaceIdentifiers.map((identifier) => + requestWorkspaceDiagnosticReport(filePath, identifier), + ), + ]), + ) + } + + function waitForRegistrationChange(timeout: number) { + if (timeout <= 0) return Promise.resolve(false) + return new Promise((resolve) => { + let finished = false + let timer: ReturnType | undefined + const finish = (result: boolean) => { + if (finished) return + finished = true + if (timer) clearTimeout(timer) + registrationListeners.delete(listener) + resolve(result) + } + const listener = () => finish(true) + registrationListeners.add(listener) + timer = setTimeout(() => finish(false), timeout) + }) + } + + function waitForFreshPush(request: { path: string; version: number; after: number; timeout: number }) { + if (request.timeout <= 0) return Promise.resolve(false) + return new Promise((resolve) => { + let finished = false + let debounceTimer: ReturnType | undefined + let timeoutTimer: ReturnType | undefined + let unsub: (() => void) | undefined + const finish = (result: boolean) => { + if (finished) return + finished = true + if (debounceTimer) clearTimeout(debounceTimer) + if (timeoutTimer) clearTimeout(timeoutTimer) + unsub?.() + resolve(result) + } + const schedule = () => { + const hit = published.get(request.path) + if (!hit) return + if (typeof hit.version === "number" && hit.version !== request.version) return + if (hit.at < request.after && hit.version !== request.version) return + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => finish(true), Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at))) + } + + timeoutTimer = setTimeout(() => finish(false), request.timeout) + const listener = (event: { path: string; serverID: string }) => { + if (event.path !== request.path || event.serverID !== input.serverID) return + schedule() + } + diagnosticListeners.add(listener) + unsub = () => diagnosticListeners.delete(listener) + schedule() + }) + } + + async function waitForDocumentDiagnostics(request: { path: string; version: number; after?: number }) { + const startedAt = request.after ?? Date.now() + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS, + }) + + while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) { + const result = await requestDocumentDiagnostics(request.path) + if (result.matched) return + const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt) + if (remaining <= 0) return + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : ("timeout" as const))), + waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))), + ]) + if (next !== "registration") return + } + } + + async function waitForFullDiagnostics(request: { path: string; version: number; after?: number }) { + const startedAt = request.after ?? Date.now() + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS, + }) + + while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) { + const result = await requestFullDiagnostics(request.path) + if (result.handled || result.matched) return + const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt) + if (remaining <= 0) return + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : ("timeout" as const))), + waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))), + ]) + if (next !== "registration") return + } + } + + // --- Public API --- + + const result = { + root: input.root, + get serverID() { + return input.serverID + }, + get connection() { + return connection + }, + notify: { + async open(request: { path: string }) { + request.path = Filesystem.normalizePath( + path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), + ) + const text = await Filesystem.readText(request.path) + const extension = path.extname(request.path) + const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" + + const document = files[request.path] + if (document !== undefined) { + // Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only + // re-emit diagnostics when the content actually changes, so clearing + // here would lose errors for no-op touchFile calls. Let the server's + // next push/pull overwrite naturally. + await connection.sendNotification("workspace/didChangeWatchedFiles", { + changes: [ + { + uri: pathToFileURL(request.path).href, + type: FILE_CHANGE_CHANGED, + }, + ], + }) + + const next = document.version + 1 + files[request.path] = { version: next, text } + await connection.sendNotification("textDocument/didChange", { + textDocument: { + uri: pathToFileURL(request.path).href, + version: next, + }, + contentChanges: + syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL + ? [ + { + range: { + start: { line: 0, character: 0 }, + end: endPosition(document.text), + }, + text, + }, + ] + : [{ text }], + }) + return next + } + + await connection.sendNotification("workspace/didChangeWatchedFiles", { + changes: [ + { + uri: pathToFileURL(request.path).href, + type: FILE_CHANGE_CREATED, + }, + ], + }) + + pushDiagnostics.delete(request.path) + pullDiagnostics.delete(request.path) + await connection.sendNotification("textDocument/didOpen", { + textDocument: { + uri: pathToFileURL(request.path).href, + languageId, + version: 0, + text, + }, + }) + files[request.path] = { version: 0, text } + return 0 + }, + }, + get diagnostics() { + const result = new Map() + for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) { + result.set(key, mergedDiagnostics(key)) + } + return result + }, + async waitForDiagnostics(request: { path: string; version: number; mode?: "document" | "full"; after?: number }) { + const normalizedPath = Filesystem.normalizePath( + path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), + ) + if (request.mode === "document") { + await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version, after: request.after }) + return + } + await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after }) + }, + async shutdown() { + connection.end() + connection.dispose() + await Process.stop(input.server.process) + }, + } + + return result +} + +export * as LSPClient from "./client" diff --git a/packages/opencode/src/lsp/diagnostic.ts b/packages/opencode/src/lsp/diagnostic.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bc085e788e40ea22d8f990b93a2a2a2c50520f8 --- /dev/null +++ b/packages/opencode/src/lsp/diagnostic.ts @@ -0,0 +1,29 @@ +import * as LSPClient from "./client" + +const MAX_PER_FILE = 20 + +export function pretty(diagnostic: LSPClient.Diagnostic) { + const severityMap = { + 1: "ERROR", + 2: "WARN", + 3: "INFO", + 4: "HINT", + } + + const severity = severityMap[diagnostic.severity || 1] + const line = diagnostic.range.start.line + 1 + const col = diagnostic.range.start.character + 1 + + return `${severity} [${line}:${col}] ${diagnostic.message}` +} + +export function report(file: string, issues: LSPClient.Diagnostic[]) { + const errors = issues.filter((item) => item.severity === 1) + if (errors.length === 0) return "" + const limited = errors.slice(0, MAX_PER_FILE) + const more = errors.length - MAX_PER_FILE + const suffix = more > 0 ? `\n... and ${more} more` : "" + return `\n${limited.map(pretty).join("\n")}${suffix}\n` +} + +export * as Diagnostic from "./diagnostic" diff --git a/packages/opencode/src/lsp/language.ts b/packages/opencode/src/lsp/language.ts new file mode 100644 index 0000000000000000000000000000000000000000..07a2e97231e856685e2a5cead7a56d3f53907d14 --- /dev/null +++ b/packages/opencode/src/lsp/language.ts @@ -0,0 +1,121 @@ +export const LANGUAGE_EXTENSIONS: Record = { + ".abap": "abap", + ".bat": "bat", + ".bib": "bibtex", + ".bibtex": "bibtex", + ".clj": "clojure", + ".cljs": "clojure", + ".cljc": "clojure", + ".edn": "clojure", + ".coffee": "coffeescript", + ".c": "c", + ".cpp": "cpp", + ".cxx": "cpp", + ".cc": "cpp", + ".c++": "cpp", + ".cs": "csharp", + ".csx": "csharp", + ".css": "css", + ".d": "d", + ".pas": "pascal", + ".pascal": "pascal", + ".diff": "diff", + ".patch": "diff", + ".dart": "dart", + ".dockerfile": "dockerfile", + ".ex": "elixir", + ".exs": "elixir", + ".erl": "erlang", + ".ets": "typescript", + ".hrl": "erlang", + ".fs": "fsharp", + ".fsi": "fsharp", + ".fsx": "fsharp", + ".fsscript": "fsharp", + ".gitcommit": "git-commit", + ".gitrebase": "git-rebase", + ".go": "go", + ".groovy": "groovy", + ".gleam": "gleam", + ".hbs": "handlebars", + ".handlebars": "handlebars", + ".hs": "haskell", + ".lhs": "haskell", + ".html": "html", + ".htm": "html", + ".ini": "ini", + ".java": "java", + ".jl": "julia", + ".js": "javascript", + ".kt": "kotlin", + ".kts": "kotlin", + ".jsx": "javascriptreact", + ".json": "json", + ".tex": "latex", + ".latex": "latex", + ".less": "less", + ".lua": "lua", + ".makefile": "makefile", + makefile: "makefile", + ".md": "markdown", + ".markdown": "markdown", + ".m": "objective-c", + ".mm": "objective-cpp", + ".pl": "perl", + ".pm": "perl", + ".pm6": "perl6", + ".php": "php", + ".ps1": "powershell", + ".psm1": "powershell", + ".pug": "jade", + ".jade": "jade", + ".py": "python", + ".r": "r", + ".cshtml": "razor", + ".razor": "razor", + ".rb": "ruby", + ".rake": "ruby", + ".gemspec": "ruby", + ".ru": "ruby", + ".erb": "erb", + ".html.erb": "erb", + ".js.erb": "erb", + ".css.erb": "erb", + ".json.erb": "erb", + ".rs": "rust", + ".scss": "scss", + ".sass": "sass", + ".scala": "scala", + ".shader": "shaderlab", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", + ".ksh": "shellscript", + ".sql": "sql", + ".svelte": "svelte", + ".swift": "swift", + ".ts": "typescript", + ".tsx": "typescriptreact", + ".mts": "typescript", + ".cts": "typescript", + ".mtsx": "typescriptreact", + ".ctsx": "typescriptreact", + ".xml": "xml", + ".xsl": "xsl", + ".yaml": "yaml", + ".yml": "yaml", + ".mjs": "javascript", + ".cjs": "javascript", + ".vue": "vue", + ".zig": "zig", + ".zon": "zig", + ".astro": "astro", + ".ml": "ocaml", + ".mli": "ocaml", + ".tf": "terraform", + ".tfvars": "terraform-vars", + ".hcl": "hcl", + ".nix": "nix", + ".typ": "typst", + ".typc": "typst", +} as const diff --git a/packages/opencode/src/lsp/launch.ts b/packages/opencode/src/lsp/launch.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f687b1972bbfa9c26557b7ddf31d2cd712fe539 --- /dev/null +++ b/packages/opencode/src/lsp/launch.ts @@ -0,0 +1,21 @@ +import type { ChildProcessWithoutNullStreams } from "child_process" +import { Process } from "@/util/process" + +type Child = Process.Child & ChildProcessWithoutNullStreams + +export function spawn(cmd: string, args: string[], opts?: Process.Options): Child +export function spawn(cmd: string, opts?: Process.Options): Child +export function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts?: Process.Options) { + const args = Array.isArray(argsOrOpts) ? [...argsOrOpts] : [] + const cfg = Array.isArray(argsOrOpts) ? opts : argsOrOpts + const proc = Process.spawn([cmd, ...args], { + ...cfg, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }) as Child + + if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available") + + return proc +} diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ab3a0b82369f1b1f9e427a7513981be4f69cb73 --- /dev/null +++ b/packages/opencode/src/lsp/lsp.ts @@ -0,0 +1,507 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { EventV2Bridge } from "@/event-v2-bridge" +import * as LSPClient from "./client" +import path from "path" +import { pathToFileURL, fileURLToPath } from "url" +import * as LSPServer from "./server" +import { Config } from "@/config/config" +import { Process } from "@/util/process" +import { spawn as lspspawn } from "./launch" +import { Effect, Layer, Context, Schema } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { containsPath } from "@/project/instance-context" +import { NonNegativeInt } from "@opencode-ai/core/schema" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { LspEvent } from "@opencode-ai/schema/lsp-event" + +export const Event = LspEvent + +const Position = Schema.Struct({ + line: NonNegativeInt, + character: NonNegativeInt, +}) + +export const Range = Schema.Struct({ + start: Position, + end: Position, +}).annotate({ identifier: "Range" }) +export type Range = typeof Range.Type + +export const Symbol = Schema.Struct({ + name: Schema.String, + kind: NonNegativeInt, + location: Schema.Struct({ + uri: Schema.String, + range: Range, + }), +}).annotate({ identifier: "Symbol" }) +export type Symbol = typeof Symbol.Type + +export const DocumentSymbol = Schema.Struct({ + name: Schema.String, + detail: Schema.optional(Schema.String), + kind: NonNegativeInt, + range: Range, + selectionRange: Range, +}).annotate({ identifier: "DocumentSymbol" }) +export type DocumentSymbol = typeof DocumentSymbol.Type + +export const Status = Schema.Struct({ + id: Schema.String, + name: Schema.String, + root: Schema.String, + status: Schema.Literals(["connected", "error"]), +}).annotate({ identifier: "LSPStatus" }) +export type Status = typeof Status.Type + +enum SymbolKind { + File = 1, + Module = 2, + Namespace = 3, + Package = 4, + Class = 5, + Method = 6, + Property = 7, + Field = 8, + Constructor = 9, + Enum = 10, + Interface = 11, + Function = 12, + Variable = 13, + Constant = 14, + String = 15, + Number = 16, + Boolean = 17, + Array = 18, + Object = 19, + Key = 20, + Null = 21, + EnumMember = 22, + Struct = 23, + Event = 24, + Operator = 25, + TypeParameter = 26, +} + +const kinds = [ + SymbolKind.Class, + SymbolKind.Function, + SymbolKind.Method, + SymbolKind.Interface, + SymbolKind.Variable, + SymbolKind.Constant, + SymbolKind.Struct, + SymbolKind.Enum, +] + +const filterExperimentalServers = (servers: Record, flags: RuntimeFlags.Info) => { + if (flags.experimentalLspTy) { + if (servers["pyright"]) { + delete servers["pyright"] + } + } else { + if (servers["ty"]) { + delete servers["ty"] + } + } +} + +type LocInput = { file: string; line: number; character: number } + +interface State { + clients: LSPClient.Info[] + servers: Record + broken: Set + spawning: Map> +} + +export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly hasClients: (file: string) => Effect.Effect + readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect + readonly diagnostics: () => Effect.Effect> + readonly hover: (input: LocInput) => Effect.Effect + readonly definition: (input: LocInput) => Effect.Effect + readonly references: (input: LocInput) => Effect.Effect + readonly implementation: (input: LocInput) => Effect.Effect + readonly documentSymbol: (uri: string) => Effect.Effect<(DocumentSymbol | Symbol)[]> + readonly workspaceSymbol: (query: string) => Effect.Effect + readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect + readonly incomingCalls: (input: LocInput) => Effect.Effect + readonly outgoingCalls: (input: LocInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/LSP") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const flags = yield* RuntimeFlags.Service + const events = yield* EventV2Bridge.Service + + const state = yield* InstanceState.make( + Effect.fn("LSP.state")(function* (ctx) { + const cfg = yield* config.get() + + const servers: Record = {} + + if (!cfg.lsp) { + yield* Effect.logInfo("all LSPs are disabled") + } else { + for (const server of Object.values(LSPServer)) { + servers[server.id] = server + } + + filterExperimentalServers(servers, flags) + + if (cfg.lsp !== true) { + for (const [name, item] of Object.entries(cfg.lsp)) { + const existing = servers[name] + if (item.disabled) { + yield* Effect.logInfo(`LSP server ${name} is disabled`) + delete servers[name] + continue + } + servers[name] = { + ...existing, + id: name, + root: existing?.root ?? (async (_file, ctx) => ctx.directory), + extensions: item.extensions ?? existing?.extensions ?? [], + spawn: async (root) => ({ + process: lspspawn(item.command[0], item.command.slice(1), { + cwd: root, + env: { ...process.env, ...item.env }, + }), + initialization: item.initialization, + }), + } + } + } + + yield* Effect.logInfo("enabled LSP servers", { + serverIds: Object.values(servers) + .map((server) => server.id) + .join(", "), + }) + } + + const s: State = { + clients: [], + servers, + broken: new Set(), + spawning: new Map(), + } + + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await Promise.all(s.clients.map((client) => client.shutdown())) + }), + ) + + return s + }), + ) + + const getClients = Effect.fnUntraced(function* (file: string) { + const ctx = yield* InstanceState.context + if (!containsPath(file, ctx)) return [] as LSPClient.Info[] + const s = yield* InstanceState.get(state) + const clients = yield* Effect.promise(async () => { + const extension = path.parse(file).ext || file + const result: LSPClient.Info[] = [] + let updated = 0 + + async function schedule(server: LSPServer.Info, root: string, key: string) { + const handle = await server + .spawn(root, ctx, flags) + .then((value) => { + if (!value) s.broken.add(key) + return value + }) + .catch(() => { + s.broken.add(key) + return undefined + }) + + if (!handle) return undefined + const client = await LSPClient.create({ + serverID: server.id, + server: handle, + root, + directory: ctx.directory, + instance: ctx, + }).catch(async () => { + s.broken.add(key) + await Process.stop(handle.process) + return undefined + }) + + if (!client) return undefined + + const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) + if (existing) { + await Process.stop(handle.process) + return existing + } + + s.clients.push(client) + return client + } + + for (const server of Object.values(s.servers)) { + if (server.extensions.length && !server.extensions.includes(extension)) continue + + const root = await server.root(file, ctx) + if (!root) continue + if (s.broken.has(root + server.id)) continue + + const match = s.clients.find((x) => x.root === root && x.serverID === server.id) + if (match) { + result.push(match) + continue + } + + const inflight = s.spawning.get(root + server.id) + if (inflight) { + const client = await inflight + if (!client) continue + result.push(client) + continue + } + + const task = schedule(server, root, root + server.id) + s.spawning.set(root + server.id, task) + + task.finally(() => { + if (s.spawning.get(root + server.id) === task) { + s.spawning.delete(root + server.id) + } + }) + + const client = await task + if (!client) continue + + result.push(client) + updated++ + } + + return { result, updated } + }) + yield* Effect.forEach(Array.from({ length: clients.updated }), () => events.publish(Event.Updated, {}), { + discard: true, + }) + return clients.result + }) + + const run = Effect.fnUntraced(function* (file: string, fn: (client: LSPClient.Info) => Promise) { + const clients = yield* getClients(file) + return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x)))) + }) + + const runAll = Effect.fnUntraced(function* (fn: (client: LSPClient.Info) => Promise) { + const s = yield* InstanceState.get(state) + return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x)))) + }) + + const init = Effect.fn("LSP.init")(function* () { + yield* InstanceState.get(state) + }) + + const status = Effect.fn("LSP.status")(function* () { + const ctx = yield* InstanceState.context + const s = yield* InstanceState.get(state) + const result: Status[] = [] + for (const client of s.clients) { + result.push({ + id: client.serverID, + name: s.servers[client.serverID].id, + root: path.relative(ctx.directory, client.root), + status: "connected", + }) + } + return result + }) + + const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { + const ctx = yield* InstanceState.context + const s = yield* InstanceState.get(state) + return yield* Effect.promise(async () => { + const extension = path.parse(file).ext || file + for (const server of Object.values(s.servers)) { + if (server.extensions.length && !server.extensions.includes(extension)) continue + const root = await server.root(file, ctx) + if (!root) continue + if (s.broken.has(root + server.id)) continue + return true + } + return false + }) + }) + + const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") { + yield* Effect.logInfo("touching file", { file: input }) + const clients = yield* getClients(input) + yield* Effect.promise(() => + Promise.all( + clients.map(async (client) => { + const after = Date.now() + const version = await client.notify.open({ path: input }) + if (!diagnostics) return + return client.waitForDiagnostics({ + path: input, + version, + mode: diagnostics, + after, + }) + }), + ).catch(() => {}), + ) + }) + + const diagnostics = Effect.fn("LSP.diagnostics")(function* () { + const results: Record = {} + const all = yield* runAll(async (client) => client.diagnostics) + for (const result of all) { + for (const [p, diags] of result.entries()) { + const arr = results[p] || [] + arr.push(...diags) + results[p] = arr + } + } + return results + }) + + const hover = Effect.fn("LSP.hover")(function* (input: LocInput) { + return yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/hover", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ) + }) + + const definition = Effect.fn("LSP.definition")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/definition", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ) + return results.flat().filter(Boolean) + }) + + const references = Effect.fn("LSP.references")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/references", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + context: { includeDeclaration: true }, + }) + .catch(() => []), + ) + return results.flat().filter(Boolean) + }) + + const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/implementation", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ) + return results.flat().filter(Boolean) + }) + + const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) { + const file = fileURLToPath(uri) + const results = yield* run(file, (client) => + client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []), + ) + return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean) + }) + + const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) { + const results = yield* runAll((client) => + client.connection + .sendRequest("workspace/symbol", { query }) + .then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10)) + .catch(() => [] as Symbol[]), + ) + return results.flat() + }) + + const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/prepareCallHierarchy", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => []), + ) + return results.flat().filter(Boolean) + }) + + const callHierarchyRequest = Effect.fnUntraced(function* ( + input: LocInput, + direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls", + ) { + const results = yield* run(input.file, async (client) => { + const items = await client.connection + .sendRequest("textDocument/prepareCallHierarchy", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => [] as unknown[]) + if (!items?.length) return [] + return client.connection.sendRequest(direction, { item: items[0] }).catch(() => []) + }) + return results.flat().filter(Boolean) + }) + + const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) { + return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls") + }) + + const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) { + return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls") + }) + + return Service.of({ + init, + status, + hasClients, + touchFile, + diagnostics, + hover, + definition, + references, + implementation, + documentSymbol, + workspaceSymbol, + prepareCallHierarchy, + incomingCalls, + outgoingCalls, + }) + }), +) + +export * as Diagnostic from "./diagnostic" + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Config.node, RuntimeFlags.node, FSUtil.node, EventV2Bridge.node], +}) + +export * as LSP from "./lsp" diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..2be68aa5a92d375ad8ab4f91f981961a4c8befb0 --- /dev/null +++ b/packages/opencode/src/lsp/server.ts @@ -0,0 +1,1983 @@ +import type { ChildProcessWithoutNullStreams } from "child_process" +import path from "path" +import os from "os" +import { Global } from "@opencode-ai/core/global" +import { text } from "node:stream/consumers" +import fs from "fs/promises" +import { Filesystem } from "@/util/filesystem" +import type { InstanceContext } from "../project/instance-context" +import { Archive } from "@/util/archive" +import { Process } from "@/util/process" +import { which } from "@opencode-ai/core/util/which" +import { Module } from "@opencode-ai/core/util/module" +import { spawn } from "./launch" +import { Npm } from "@opencode-ai/core/npm" +import type { RuntimeFlags } from "@/effect/runtime-flags" + +const pathExists = async (p: string) => + fs + .stat(p) + .then(() => true) + .catch(() => false) +const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true }) +const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true }) + +export interface Handle { + process: ChildProcessWithoutNullStreams + initialization?: Record +} + +type RootFunction = (file: string, ctx: InstanceContext) => Promise + +const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => { + return async (file, ctx) => { + if (excludePatterns) { + const excludedFiles = Filesystem.up({ + targets: excludePatterns, + start: path.dirname(file), + stop: ctx.directory, + }) + const excluded = await excludedFiles.next() + await excludedFiles.return() + if (excluded.value) return undefined + } + const files = Filesystem.up({ + targets: includePatterns, + start: path.dirname(file), + stop: ctx.directory, + }) + const first = await files.next() + await files.return() + if (!first.value) return ctx.directory + return path.dirname(first.value) + } +} + +const StrictNearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => { + return async (file, ctx) => { + if (excludePatterns) { + const excludedFiles = Filesystem.up({ + targets: excludePatterns, + start: path.dirname(file), + stop: ctx.directory, + }) + const excluded = await excludedFiles.next() + await excludedFiles.return() + if (excluded.value) return undefined + } + const files = Filesystem.up({ + targets: includePatterns, + start: path.dirname(file), + stop: ctx.directory, + }) + const first = await files.next() + await files.return() + if (!first.value) return undefined + return path.dirname(first.value) + } +} + +export interface Info { + id: string + extensions: string[] + global?: boolean + root: RootFunction + spawn(root: string, ctx: InstanceContext, flags: RuntimeFlags.Info): Promise +} + +export const Deno: Info = { + id: "deno", + root: async (file, ctx) => { + const files = Filesystem.up({ + targets: ["deno.json", "deno.jsonc"], + start: path.dirname(file), + stop: ctx.directory, + }) + const first = await files.next() + await files.return() + if (!first.value) return undefined + return path.dirname(first.value) + }, + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], + async spawn(root) { + const deno = which("deno") + if (!deno) { + return + } + return { + process: spawn(deno, ["lsp"], { + cwd: root, + }), + } + }, +} + +export const Typescript: Info = { + id: "typescript", + root: NearestRoot( + ["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"], + ["deno.json", "deno.jsonc"], + ), + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], + async spawn(root, ctx) { + const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory) + if (!tsserver) return + const bin = await Npm.which("typescript-language-server") + if (!bin) return + const proc = spawn(bin, ["--stdio"], { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: { + tsserver: { + path: tsserver, + }, + }, + } + }, +} + +export const Vue: Info = { + id: "vue", + extensions: [".vue"], + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + async spawn(root, _ctx, flags) { + let binary = which("vue-language-server") + const args: string[] = [] + if (!binary) { + if (flags.disableLspDownload) return + const resolved = await Npm.which("@vue/language-server") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: { + // Leave empty; the server will auto-detect workspace TypeScript. + }, + } + }, +} + +export const ESLint: Info = { + id: "eslint", + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], + async spawn(root, ctx, flags) { + const eslint = Module.resolve("eslint", ctx.directory) + if (!eslint) return + const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") + if (!(await Filesystem.exists(serverPath))) { + if (flags.disableLspDownload) return + const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip") + if (!response.ok) return + + const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip") + if (response.body) await Filesystem.writeStream(zipPath, response.body) + + const ok = await Archive.extractZip(zipPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + return false + }) + if (!ok) return + await fs.rm(zipPath, { force: true }) + + const extractedPath = path.join(Global.Path.bin, "vscode-eslint-main") + const finalPath = path.join(Global.Path.bin, "vscode-eslint") + + const stats = await fs.stat(finalPath).catch(() => undefined) + if (stats) { + await fs.rm(finalPath, { force: true, recursive: true }) + } + await fs.rename(extractedPath, finalPath) + + const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm" + await Process.run([npmCmd, "install"], { cwd: finalPath }) + await Process.run([npmCmd, "run", "compile"], { cwd: finalPath }) + } + + const proc = spawn("node", [serverPath, "--stdio"], { + cwd: root, + env: { + ...process.env, + }, + }) + + return { + process: proc, + } + }, +} + +export const Oxlint: Info = { + id: "oxlint", + root: NearestRoot([ + ".oxlintrc.json", + "package-lock.json", + "bun.lockb", + "bun.lock", + "pnpm-lock.yaml", + "yarn.lock", + "package.json", + ]), + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"], + async spawn(root, ctx) { + const ext = process.platform === "win32" ? ".cmd" : "" + + const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext) + const lintTarget = path.join("node_modules", ".bin", "oxlint" + ext) + + const resolveBin = async (target: string) => { + const localBin = path.join(root, target) + if (await Filesystem.exists(localBin)) return localBin + + const candidates = Filesystem.up({ + targets: [target], + start: root, + stop: ctx.worktree, + }) + const first = await candidates.next() + await candidates.return() + if (first.value) return first.value + + return undefined + } + + let lintBin = await resolveBin(lintTarget) + if (!lintBin) { + const found = which("oxlint") + if (found) lintBin = found + } + + if (lintBin) { + const proc = spawn(lintBin, ["--help"]) + await proc.exited + if (proc.stdout) { + const help = await text(proc.stdout) + if (help.includes("--lsp")) { + return { + process: spawn(lintBin, ["--lsp"], { + cwd: root, + }), + } + } + } + } + + let serverBin = await resolveBin(serverTarget) + if (!serverBin) { + const found = which("oxc_language_server") + if (found) serverBin = found + } + if (serverBin) { + return { + process: spawn(serverBin, [], { + cwd: root, + }), + } + } + + return + }, +} + +export const Biome: Info = { + id: "biome", + root: NearestRoot([ + "biome.json", + "biome.jsonc", + "package-lock.json", + "bun.lockb", + "bun.lock", + "pnpm-lock.yaml", + "yarn.lock", + ]), + extensions: [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".mts", + ".cts", + ".json", + ".jsonc", + ".vue", + ".astro", + ".svelte", + ".css", + ".graphql", + ".gql", + ".html", + ], + async spawn(root) { + const localBin = path.join(root, "node_modules", ".bin", "biome") + let bin: string | undefined + if (await Filesystem.exists(localBin)) bin = localBin + if (!bin) { + const found = which("biome") + if (found) bin = found + } + + let args = ["lsp-proxy", "--stdio"] + + if (!bin) { + const resolved = Module.resolve("biome", root) + if (!resolved) return + bin = await Npm.which("biome") + if (!bin) return + args = ["lsp-proxy", "--stdio"] + } + + const proc = spawn(bin, args, { + cwd: root, + env: { + ...process.env, + }, + }) + + return { + process: proc, + } + }, +} + +export const Gopls: Info = { + id: "gopls", + root: async (file, ctx) => { + const work = await NearestRoot(["go.work"])(file, ctx) + if (work) return work + return NearestRoot(["go.mod", "go.sum"])(file, ctx) + }, + extensions: [".go"], + async spawn(root, _ctx, flags) { + let bin = which("gopls") + if (!bin) { + if (!which("go")) return + if (flags.disableLspDownload) return + + const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], { + env: { ...process.env, GOBIN: Global.Path.bin }, + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + return + } + bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : "")) + } + return { + process: spawn(bin!, { + cwd: root, + }), + } + }, +} + +export const Rubocop: Info = { + id: "ruby-lsp", + root: NearestRoot(["Gemfile"]), + extensions: [".rb", ".rake", ".gemspec", ".ru"], + async spawn(root, _ctx, flags) { + let bin = which("rubocop") + if (!bin) { + const ruby = which("ruby") + const gem = which("gem") + if (!ruby || !gem) { + return + } + if (flags.disableLspDownload) return + const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], { + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + return + } + bin = path.join(Global.Path.bin, "rubocop" + (process.platform === "win32" ? ".exe" : "")) + } + return { + process: spawn(bin!, ["--lsp"], { + cwd: root, + }), + } + }, +} + +export const Ty: Info = { + id: "ty", + extensions: [".py", ".pyi"], + root: NearestRoot([ + "pyproject.toml", + "ty.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + "Pipfile", + "pyrightconfig.json", + ]), + async spawn(root, _ctx, flags) { + if (!flags.experimentalLspTy) { + return undefined + } + + let binary = which("ty") + + const initialization: Record = {} + + const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( + (p): p is string => p !== undefined, + ) + for (const venvPath of potentialVenvPaths) { + const isWindows = process.platform === "win32" + const potentialPythonPath = isWindows + ? path.join(venvPath, "Scripts", "python.exe") + : path.join(venvPath, "bin", "python") + if (await Filesystem.exists(potentialPythonPath)) { + initialization["pythonPath"] = potentialPythonPath + break + } + } + + if (!binary) { + for (const venvPath of potentialVenvPaths) { + const isWindows = process.platform === "win32" + const potentialTyPath = isWindows ? path.join(venvPath, "Scripts", "ty.exe") : path.join(venvPath, "bin", "ty") + if (await Filesystem.exists(potentialTyPath)) { + binary = potentialTyPath + break + } + } + } + + if (!binary) { + return + } + + const proc = spawn(binary, ["server"], { + cwd: root, + }) + + return { + process: proc, + initialization, + } + }, +} + +export const Pyright: Info = { + id: "pyright", + extensions: [".py", ".pyi"], + root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]), + async spawn(root, _ctx, flags) { + let binary = which("pyright-langserver") + const args = [] + if (!binary) { + if (flags.disableLspDownload) return + const resolved = await Npm.which("pyright", "pyright-langserver") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + + const initialization: Record = {} + + const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( + (p): p is string => p !== undefined, + ) + for (const venvPath of potentialVenvPaths) { + const isWindows = process.platform === "win32" + const potentialPythonPath = isWindows + ? path.join(venvPath, "Scripts", "python.exe") + : path.join(venvPath, "bin", "python") + if (await Filesystem.exists(potentialPythonPath)) { + initialization["pythonPath"] = potentialPythonPath + break + } + } + + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization, + } + }, +} + +export const ElixirLS: Info = { + id: "elixir-ls", + extensions: [".ex", ".exs"], + root: NearestRoot(["mix.exs", "mix.lock"]), + async spawn(root, _ctx, flags) { + let binary = which("elixir-ls") + if (!binary) { + const elixirLsPath = path.join(Global.Path.bin, "elixir-ls") + binary = path.join( + Global.Path.bin, + "elixir-ls-master", + "release", + process.platform === "win32" ? "language_server.bat" : "language_server.sh", + ) + + if (!(await Filesystem.exists(binary))) { + const elixir = which("elixir") + if (!elixir) { + return + } + + if (flags.disableLspDownload) return + + const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip") + if (!response.ok) return + const zipPath = path.join(Global.Path.bin, "elixir-ls.zip") + if (response.body) await Filesystem.writeStream(zipPath, response.body) + + const ok = await Archive.extractZip(zipPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + return false + }) + if (!ok) return + + await fs.rm(zipPath, { + force: true, + recursive: true, + }) + + const cwd = path.join(Global.Path.bin, "elixir-ls-master") + const env = { MIX_ENV: "prod", ...process.env } + await Process.run(["mix", "deps.get"], { cwd, env }) + await Process.run(["mix", "compile"], { cwd, env }) + await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env }) + } + } + + return { + process: spawn(binary, { + cwd: root, + }), + } + }, +} + +export const Zls: Info = { + id: "zls", + extensions: [".zig", ".zon"], + root: NearestRoot(["build.zig"]), + async spawn(root, _ctx, flags) { + let bin = which("zls") + + if (!bin) { + const zig = which("zig") + if (!zig) { + return + } + + if (flags.disableLspDownload) return + + const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest") + if (!releaseResponse.ok) { + return + } + + const release = (await releaseResponse.json()) as { + assets?: { name?: string; browser_download_url?: string }[] + } + + const platform = process.platform + const arch = process.arch + let assetName = "" + + let zlsArch: string = arch + if (arch === "arm64") zlsArch = "aarch64" + else if (arch === "x64") zlsArch = "x86_64" + else if (arch === "ia32") zlsArch = "x86" + + let zlsPlatform: string = platform + if (platform === "darwin") zlsPlatform = "macos" + else if (platform === "win32") zlsPlatform = "windows" + + const ext = platform === "win32" ? "zip" : "tar.xz" + + assetName = `zls-${zlsArch}-${zlsPlatform}.${ext}` + + const supportedCombos = [ + "zls-x86_64-linux.tar.xz", + "zls-x86_64-macos.tar.xz", + "zls-x86_64-windows.zip", + "zls-aarch64-linux.tar.xz", + "zls-aarch64-macos.tar.xz", + "zls-aarch64-windows.zip", + "zls-x86-linux.tar.xz", + "zls-x86-windows.zip", + ] + + if (!supportedCombos.includes(assetName)) { + return + } + + const asset = release.assets?.find((a) => a.name === assetName) + if (!asset?.browser_download_url) { + return + } + + const downloadUrl = asset.browser_download_url + const downloadResponse = await fetch(downloadUrl) + if (!downloadResponse.ok) { + return + } + + const tempPath = path.join(Global.Path.bin, assetName) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + + if (ext === "zip") { + const ok = await Archive.extractZip(tempPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + return false + }) + if (!ok) return + } else { + await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin }) + } + + await fs.rm(tempPath, { force: true }) + + bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : "")) + + if (!(await Filesystem.exists(bin))) { + return + } + + if (platform !== "win32") { + await fs.chmod(bin, 0o755).catch(() => {}) + } + } + + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} + +export const CSharp: Info = { + id: "csharp", + root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), + extensions: [".cs", ".csx"], + async spawn(root, _ctx, flags) { + const bin = await getRoslynLanguageServer(flags.disableLspDownload) + if (!bin) return + + return { + process: spawn(bin, ["--stdio", "--autoLoadProjects"], { + cwd: root, + }), + } + }, +} + +export const Razor: Info = { + id: "razor", + root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), + extensions: [".razor", ".cshtml"], + async spawn(root, _ctx, flags) { + const bin = await getRoslynLanguageServer(flags.disableLspDownload) + if (!bin) return + + const razor = await findVscodeRazorExtension() + if (!razor) { + return + } + + return { + process: spawn( + bin, + [ + "--stdio", + "--autoLoadProjects", + `--razorSourceGenerator=${razor.compiler}`, + `--razorDesignTimePath=${razor.targets}`, + "--extension", + razor.extension, + ], + { + cwd: root, + }, + ), + } + }, +} + +let roslynLanguageServerInstall: Promise | undefined + +async function getRoslynLanguageServer(disableLspDownload: boolean) { + const existing = which("roslyn-language-server") + if (existing) return existing + + const global = await roslynLanguageServerGlobalPath() + if (global) return global + + roslynLanguageServerInstall ||= installRoslynLanguageServer(disableLspDownload).finally(() => { + roslynLanguageServerInstall = undefined + }) + return roslynLanguageServerInstall +} + +async function installRoslynLanguageServer(disableLspDownload: boolean) { + if (!which("dotnet")) { + return + } + + if (disableLspDownload) return + const proc = Process.spawn(["dotnet", "tool", "install", "--global", "roslyn-language-server", "--prerelease"], { + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + return + } + + const resolved = which("roslyn-language-server") + if (resolved) { + return resolved + } + + const global = await roslynLanguageServerGlobalPath() + if (global) { + return global + } +} + +async function roslynLanguageServerGlobalPath() { + const bin = path.join( + process.env.DOTNET_CLI_HOME ?? os.homedir(), + ".dotnet", + "tools", + "roslyn-language-server" + (process.platform === "win32" ? ".cmd" : ""), + ) + return (await pathExists(bin)) ? bin : undefined +} + +async function findVscodeRazorExtension() { + const roots = [ + process.env.VSCODE_EXTENSIONS, + path.join(os.homedir(), ".vscode", "extensions"), + path.join(os.homedir(), ".vscode-insiders", "extensions"), + path.join(os.homedir(), ".vscode-server", "extensions"), + path.join(os.homedir(), ".vscode-server-insiders", "extensions"), + ].filter((item) => item !== undefined) + + for (const root of [...new Set(roots)]) { + const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []) + const candidates = await Promise.all( + entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith("ms-dotnettools.csharp-")) + .map(async (entry) => ({ + path: path.join(root, entry.name, ".razorExtension"), + modified: (await fs.stat(path.join(root, entry.name)).catch(() => undefined))?.mtimeMs ?? 0, + })), + ) + for (const entry of candidates.sort((a, b) => b.modified - a.modified).map((candidate) => candidate.path)) { + const result = { + compiler: path.join(entry, "Microsoft.CodeAnalysis.Razor.Compiler.dll"), + targets: path.join(entry, "Targets", "Microsoft.NET.Sdk.Razor.DesignTime.targets"), + extension: path.join(entry, "Microsoft.VisualStudioCode.RazorExtension.dll"), + } + if ( + (await pathExists(result.compiler)) && + (await pathExists(result.targets)) && + (await pathExists(result.extension)) + ) { + return result + } + } + } +} + +export const FSharp: Info = { + id: "fsharp", + root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]), + extensions: [".fs", ".fsi", ".fsx", ".fsscript"], + async spawn(root, _ctx, flags) { + let bin = which("fsautocomplete") + if (!bin) { + if (!which("dotnet")) { + return + } + + if (flags.disableLspDownload) return + const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], { + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + return + } + + bin = path.join(Global.Path.bin, "fsautocomplete" + (process.platform === "win32" ? ".exe" : "")) + } + + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} + +export const SourceKit: Info = { + id: "sourcekit-lsp", + extensions: [".swift", ".objc", "objcpp"], + root: NearestRoot(["Package.swift", "*.xcodeproj", "*.xcworkspace"]), + async spawn(root) { + // Check if sourcekit-lsp is available in the PATH + // This is installed with the Swift toolchain + const sourcekit = which("sourcekit-lsp") + if (sourcekit) { + return { + process: spawn(sourcekit, { + cwd: root, + }), + } + } + + // If sourcekit-lsp not found, check if xcrun is available + // This is specific to macOS where sourcekit-lsp is typically installed with Xcode + if (!which("xcrun")) return + + const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"]) + + if (lspLoc.code !== 0) return + + const bin = lspLoc.text.trim() + + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} + +export const RustAnalyzer: Info = { + id: "rust", + root: async (file, ctx) => { + const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(file, ctx) + if (crateRoot === undefined) { + return undefined + } + let currentDir = crateRoot + + while (currentDir !== path.dirname(currentDir)) { + // Stop at filesystem root + const cargoTomlPath = path.join(currentDir, "Cargo.toml") + try { + const cargoTomlContent = await Filesystem.readText(cargoTomlPath) + if (cargoTomlContent.includes("[workspace]")) { + return currentDir + } + } catch { + // File doesn't exist or can't be read, continue searching up + } + + const parentDir = path.dirname(currentDir) + if (parentDir === currentDir) break // Reached filesystem root + currentDir = parentDir + + // Stop if we've gone above the app root + if (!currentDir.startsWith(ctx.worktree)) break + } + + return crateRoot + }, + extensions: [".rs"], + async spawn(root) { + const bin = which("rust-analyzer") + if (!bin) { + return + } + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} + +export const Clangd: Info = { + id: "clangd", + root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]), + extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], + async spawn(root, _ctx, flags) { + const args = ["--background-index", "--clang-tidy"] + const fromPath = which("clangd") + if (fromPath) { + return { + process: spawn(fromPath, args, { + cwd: root, + }), + } + } + + const ext = process.platform === "win32" ? ".exe" : "" + const direct = path.join(Global.Path.bin, "clangd" + ext) + if (await Filesystem.exists(direct)) { + return { + process: spawn(direct, args, { + cwd: root, + }), + } + } + + const entries = await fs.readdir(Global.Path.bin, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (!entry.isDirectory()) continue + if (!entry.name.startsWith("clangd_")) continue + const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext) + if (await Filesystem.exists(candidate)) { + return { + process: spawn(candidate, args, { + cwd: root, + }), + } + } + } + + if (flags.disableLspDownload) return + + const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest") + if (!releaseResponse.ok) { + return + } + + const release: { + tag_name?: string + assets?: { name?: string; browser_download_url?: string }[] + } = await releaseResponse.json() + + const tag = release.tag_name + if (!tag) { + return + } + const platform = process.platform + const tokens: Record = { + darwin: "mac", + linux: "linux", + win32: "windows", + } + const token = tokens[platform] + if (!token) { + return + } + + const assets = release.assets ?? [] + const valid = (item: { name?: string; browser_download_url?: string }) => { + if (!item.name) return false + if (!item.browser_download_url) return false + if (!item.name.includes(token)) return false + return item.name.includes(tag) + } + + const asset = + assets.find((item) => valid(item) && item.name?.endsWith(".zip")) ?? + assets.find((item) => valid(item) && item.name?.endsWith(".tar.xz")) ?? + assets.find((item) => valid(item)) + if (!asset?.name || !asset.browser_download_url) { + return + } + + const name = asset.name + const downloadResponse = await fetch(asset.browser_download_url) + if (!downloadResponse.ok) { + return + } + + const archive = path.join(Global.Path.bin, name) + const buf = await downloadResponse.arrayBuffer() + if (buf.byteLength === 0) { + return + } + await Filesystem.write(archive, Buffer.from(buf)) + + const zip = name.endsWith(".zip") + const tar = name.endsWith(".tar.xz") + if (!zip && !tar) { + return + } + + if (zip) { + const ok = await Archive.extractZip(archive, Global.Path.bin) + .then(() => true) + .catch((error) => { + return false + }) + if (!ok) return + } + if (tar) { + await run(["tar", "-xf", archive], { cwd: Global.Path.bin }) + } + await fs.rm(archive, { force: true }) + + const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext) + if (!(await Filesystem.exists(bin))) { + return + } + + if (platform !== "win32") { + await fs.chmod(bin, 0o755).catch(() => {}) + } + + await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {}) + await fs.symlink(bin, path.join(Global.Path.bin, "clangd")).catch(() => {}) + + return { + process: spawn(bin, args, { + cwd: root, + }), + } + }, +} + +export const Svelte: Info = { + id: "svelte", + extensions: [".svelte"], + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + async spawn(root, _ctx, flags) { + let binary = which("svelteserver") + const args: string[] = [] + if (!binary) { + if (flags.disableLspDownload) return + const resolved = await Npm.which("svelte-language-server") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: {}, + } + }, +} + +export const Astro: Info = { + id: "astro", + extensions: [".astro"], + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + async spawn(root, ctx, flags) { + const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory) + if (!tsserver) { + return + } + const tsdk = path.dirname(tsserver) + + let binary = which("astro-ls") + const args: string[] = [] + if (!binary) { + if (flags.disableLspDownload) return + const resolved = await Npm.which("@astrojs/language-server") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: { + typescript: { + tsdk, + }, + }, + } + }, +} + +function isModuleOf(pomContent: string, modulePath: string): boolean { + const normalized = modulePath.replace(/\\/g, "/").replace(/\/$/, "") + if (!normalized) return false + const modulesBlocks = pomContent.match(/([\s\S]*?)<\/modules>/g) ?? [] + for (const block of modulesBlocks) { + const stripped = block.replace(//g, "") + for (const m of stripped.matchAll(/\s*([^<]+?)\s*<\/module>/g)) { + const decl = m[1].replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "") + if (decl === normalized) return true + } + } + return false +} + +export const JDTLS: Info = { + id: "jdtls", + root: async (file, ctx) => { + const settingsMarkers = ["settings.gradle", "settings.gradle.kts"] + const gradleMarkers = ["gradlew", "gradlew.bat"] + // 1. Gradle (unchanged from original logic) + const [wrapperRoot, settingsRoot] = await Promise.all([ + StrictNearestRoot(gradleMarkers, settingsMarkers)(file, ctx), + StrictNearestRoot(settingsMarkers)(file, ctx), + ]) + if (wrapperRoot) return wrapperRoot + if (settingsRoot) return settingsRoot + + // 2. Gradle single-project fallback (build.gradle without settings.gradle) + const buildRoot = await StrictNearestRoot(["build.gradle", "build.gradle.kts"])(file, ctx) + if (buildRoot) return buildRoot + + // 3. Maven: walk up pom.xml chain verifying relationships + const pomFiles = await Filesystem.findUp("pom.xml", path.dirname(file), ctx.directory) + if (pomFiles.length > 0) { + let root = path.dirname(pomFiles[0]) + for (let i = 1; i < pomFiles.length; i++) { + const parentDir = path.dirname(pomFiles[i]) + const rel = path.relative(parentDir, root) + const content = await fs.readFile(pomFiles[i], "utf-8").catch(() => null) + if (content && isModuleOf(content, rel)) { + root = parentDir + } else { + break + } + } + return root + } + + // 4. Eclipse native project fallback + const eclipseRoot = await StrictNearestRoot([".project", ".classpath"])(file, ctx) + if (eclipseRoot) return eclipseRoot + + return undefined + }, + extensions: [".java"], + async spawn(root, _ctx, flags) { + const java = which("java") + if (!java) { + return + } + const javaMajorVersion = await run(["java", "-version"]).then((result) => { + const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString()) + return !m ? undefined : parseInt(m[1]) + }) + if (javaMajorVersion == null || javaMajorVersion < 21) { + return + } + const distPath = path.join(Global.Path.bin, "jdtls") + const launcherDir = path.join(distPath, "plugins") + const installed = await pathExists(launcherDir) + if (!installed) { + if (flags.disableLspDownload) return + await fs.mkdir(distPath, { recursive: true }) + const releaseURL = + "https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz" + const archiveName = "release.tar.gz" + + const download = await fetch(releaseURL) + if (!download.ok || !download.body) { + return + } + await Filesystem.writeStream(path.join(distPath, archiveName), download.body) + + const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath }) + if (tarResult.code !== 0) { + return + } + + await fs.rm(path.join(distPath, archiveName), { force: true }) + } + const jarFileName = + (await fs.readdir(launcherDir).catch(() => [])) + .find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item)) + ?.trim() ?? "" + const launcherJar = path.join(launcherDir, jarFileName) + if (!(await pathExists(launcherJar))) { + return + } + const configFile = path.join( + distPath, + (() => { + switch (process.platform) { + case "darwin": + return "config_mac" + case "linux": + return "config_linux" + case "win32": + return "config_win" + default: + return "config_linux" + } + })(), + ) + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jdtls-data")) + return { + process: spawn( + java, + [ + "-jar", + launcherJar, + "-configuration", + configFile, + "-data", + dataDir, + "-Declipse.application=org.eclipse.jdt.ls.core.id1", + "-Dosgi.bundles.defaultStartLevel=4", + "-Declipse.product=org.eclipse.jdt.ls.core.product", + "-Dlog.level=ALL", + "--add-modules=ALL-SYSTEM", + "--add-opens java.base/java.util=ALL-UNNAMED", + "--add-opens java.base/java.lang=ALL-UNNAMED", + ], + { + cwd: root, + }, + ), + } + }, +} + +export const KotlinLS: Info = { + id: "kotlin-ls", + extensions: [".kt", ".kts"], + root: async (file, ctx) => { + // 1) Nearest Gradle root (multi-project or included build) + const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file, ctx) + if (settingsRoot) return settingsRoot + // 2) Gradle wrapper (strong root signal) + const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file, ctx) + if (wrapperRoot) return wrapperRoot + // 3) Single-project or module-level build + const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file, ctx) + if (buildRoot) return buildRoot + // 4) Maven fallback + return NearestRoot(["pom.xml"])(file, ctx) + }, + async spawn(root, _ctx, flags) { + const distPath = path.join(Global.Path.bin, "kotlin-ls") + const launcherScript = + process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh") + const installed = await Filesystem.exists(launcherScript) + if (!installed) { + if (flags.disableLspDownload) return + + const releaseResponse = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest") + if (!releaseResponse.ok) { + return + } + + const release = await releaseResponse.json() + const version = release.name?.replace(/^v/, "") + + if (!version) { + return + } + + const platform = process.platform + const arch = process.arch + + let kotlinArch: string = arch + if (arch === "arm64") kotlinArch = "aarch64" + else if (arch === "x64") kotlinArch = "x64" + + let kotlinPlatform: string = platform + if (platform === "darwin") kotlinPlatform = "mac" + else if (platform === "linux") kotlinPlatform = "linux" + else if (platform === "win32") kotlinPlatform = "win" + + const supportedCombos = ["mac-x64", "mac-aarch64", "linux-x64", "linux-aarch64", "win-x64", "win-aarch64"] + + const combo = `${kotlinPlatform}-${kotlinArch}` + + if (!supportedCombos.includes(combo)) { + return + } + + const assetName = `kotlin-lsp-${version}-${kotlinPlatform}-${kotlinArch}.zip` + const releaseURL = `https://download-cdn.jetbrains.com/kotlin-lsp/${version}/${assetName}` + + await fs.mkdir(distPath, { recursive: true }) + const archivePath = path.join(distPath, "kotlin-ls.zip") + const download = await fetch(releaseURL) + if (!download.ok || !download.body) { + return + } + await Filesystem.writeStream(archivePath, download.body) + const ok = await Archive.extractZip(archivePath, distPath) + .then(() => true) + .catch((error) => { + return false + }) + if (!ok) return + await fs.rm(archivePath, { force: true }) + if (process.platform !== "win32") { + await fs.chmod(launcherScript, 0o755).catch(() => {}) + } + } + if (!(await Filesystem.exists(launcherScript))) { + return + } + return { + process: spawn(launcherScript, ["--stdio"], { + cwd: root, + }), + } + }, +} + +export const YamlLS: Info = { + id: "yaml-ls", + extensions: [".yaml", ".yml"], + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + async spawn(root, _ctx, flags) { + let binary = which("yaml-language-server") + const args: string[] = [] + if (!binary) { + if (flags.disableLspDownload) return + const resolved = await Npm.which("yaml-language-server") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + } + }, +} + +export const LuaLS: Info = { + id: "lua-ls", + root: NearestRoot([ + ".luarc.json", + ".luarc.jsonc", + ".luacheckrc", + ".stylua.toml", + "stylua.toml", + "selene.toml", + "selene.yml", + ]), + extensions: [".lua"], + async spawn(root, _ctx, flags) { + let bin = which("lua-language-server") + + if (!bin) { + if (flags.disableLspDownload) return + + const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest") + if (!releaseResponse.ok) { + return + } + + const release = await releaseResponse.json() + + const platform = process.platform + const arch = process.arch + let assetName = "" + + let lualsArch: string = arch + if (arch === "arm64") lualsArch = "arm64" + else if (arch === "x64") lualsArch = "x64" + else if (arch === "ia32") lualsArch = "ia32" + + let lualsPlatform: string = platform + if (platform === "darwin") lualsPlatform = "darwin" + else if (platform === "linux") lualsPlatform = "linux" + else if (platform === "win32") lualsPlatform = "win32" + + const ext = platform === "win32" ? "zip" : "tar.gz" + + assetName = `lua-language-server-${release.tag_name}-${lualsPlatform}-${lualsArch}.${ext}` + + const supportedCombos = [ + "darwin-arm64.tar.gz", + "darwin-x64.tar.gz", + "linux-x64.tar.gz", + "linux-arm64.tar.gz", + "win32-x64.zip", + "win32-ia32.zip", + ] + + const assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}` + if (!supportedCombos.includes(assetSuffix)) { + return + } + + const asset = release.assets.find((a: any) => a.name === assetName) + if (!asset) { + return + } + + const downloadUrl = asset.browser_download_url + const downloadResponse = await fetch(downloadUrl) + if (!downloadResponse.ok) { + return + } + + const tempPath = path.join(Global.Path.bin, assetName) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + + // Unlike zls which is a single self-contained binary, + // lua-language-server needs supporting files (meta/, locale/, etc.) + // Extract entire archive to dedicated directory to preserve all files + const installDir = path.join(Global.Path.bin, `lua-language-server-${lualsArch}-${lualsPlatform}`) + + // Remove old installation if exists + const stats = await fs.stat(installDir).catch(() => undefined) + if (stats) { + await fs.rm(installDir, { force: true, recursive: true }) + } + + await fs.mkdir(installDir, { recursive: true }) + + if (ext === "zip") { + const ok = await Archive.extractZip(tempPath, installDir) + .then(() => true) + .catch((error) => { + return false + }) + if (!ok) return + } else { + const ok = await run(["tar", "-xzf", tempPath, "-C", installDir]) + .then((result) => result.code === 0) + .catch((error: unknown) => { + return false + }) + if (!ok) return + } + + await fs.rm(tempPath, { force: true }) + + // Binary is located in bin/ subdirectory within the extracted archive + bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : "")) + + if (!(await Filesystem.exists(bin))) { + return + } + + if (platform !== "win32") { + const ok = await fs + .chmod(bin, 0o755) + .then(() => true) + .catch((error: unknown) => { + return false + }) + if (!ok) return + } + } + + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} + +export const PHPIntelephense: Info = { + id: "php intelephense", + extensions: [".php"], + root: NearestRoot(["composer.json", "composer.lock", ".php-version"]), + async spawn(root, _ctx, flags) { + let binary = which("intelephense") + const args: string[] = [] + if (!binary) { + if (flags.disableLspDownload) return + const resolved = await Npm.which("intelephense") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: { + telemetry: { + enabled: false, + }, + }, + } + }, +} + +export const Prisma: Info = { + id: "prisma", + extensions: [".prisma"], + root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]), + async spawn(root) { + const prisma = which("prisma") + if (!prisma) { + return + } + return { + process: spawn(prisma, ["language-server"], { + cwd: root, + }), + } + }, +} + +export const Dart: Info = { + id: "dart", + extensions: [".dart"], + root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]), + async spawn(root) { + const dart = which("dart") + if (!dart) { + return + } + return { + process: spawn(dart, ["language-server", "--lsp"], { + cwd: root, + }), + } + }, +} + +export const Ocaml: Info = { + id: "ocaml-lsp", + extensions: [".ml", ".mli"], + root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]), + async spawn(root) { + const bin = which("ocamllsp") + if (!bin) { + return + } + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} +export const BashLS: Info = { + id: "bash", + extensions: [".sh", ".bash", ".zsh", ".ksh"], + root: async (_file, ctx) => ctx.directory, + async spawn(root, _ctx, flags) { + let binary = which("bash-language-server") + const args: string[] = [] + if (!binary) { + if (flags.disableLspDownload) return + const resolved = await Npm.which("bash-language-server") + if (!resolved) return + binary = resolved + } + args.push("start") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + } + }, +} + +export const TerraformLS: Info = { + id: "terraform", + extensions: [".tf", ".tfvars"], + root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), + async spawn(root, _ctx, flags) { + let bin = which("terraform-ls") + + if (!bin) { + if (flags.disableLspDownload) return + + const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest") + if (!releaseResponse.ok) { + return + } + + const release = (await releaseResponse.json()) as { + version?: string + builds?: { arch?: string; os?: string; url?: string }[] + } + + const platform = process.platform + const arch = process.arch + + const tfArch = arch === "arm64" ? "arm64" : "amd64" + const tfPlatform = platform === "win32" ? "windows" : platform + + const builds = release.builds ?? [] + const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform) + if (!build?.url) { + return + } + + const downloadResponse = await fetch(build.url) + if (!downloadResponse.ok) { + return + } + + const tempPath = path.join(Global.Path.bin, "terraform-ls.zip") + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + + const ok = await Archive.extractZip(tempPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + return false + }) + if (!ok) return + await fs.rm(tempPath, { force: true }) + + bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : "")) + + if (!(await Filesystem.exists(bin))) { + return + } + + if (platform !== "win32") { + await fs.chmod(bin, 0o755).catch(() => {}) + } + } + + return { + process: spawn(bin, ["serve"], { + cwd: root, + }), + initialization: { + experimentalFeatures: { + prefillRequiredFields: true, + validateOnSave: true, + }, + }, + } + }, +} + +export const TexLab: Info = { + id: "texlab", + extensions: [".tex", ".bib"], + root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), + async spawn(root, _ctx, flags) { + let bin = which("texlab") + + if (!bin) { + if (flags.disableLspDownload) return + + const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest") + if (!response.ok) { + return + } + + const release = (await response.json()) as { + tag_name?: string + assets?: { name?: string; browser_download_url?: string }[] + } + const version = release.tag_name?.replace("v", "") + if (!version) { + return + } + + const platform = process.platform + const arch = process.arch + + const texArch = arch === "arm64" ? "aarch64" : "x86_64" + const texPlatform = platform === "darwin" ? "macos" : platform === "win32" ? "windows" : "linux" + const ext = platform === "win32" ? "zip" : "tar.gz" + const assetName = `texlab-${texArch}-${texPlatform}.${ext}` + + const assets = release.assets ?? [] + const asset = assets.find((a) => a.name === assetName) + if (!asset?.browser_download_url) { + return + } + + const downloadResponse = await fetch(asset.browser_download_url) + if (!downloadResponse.ok) { + return + } + + const tempPath = path.join(Global.Path.bin, assetName) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + + if (ext === "zip") { + const ok = await Archive.extractZip(tempPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + return false + }) + if (!ok) return + } + if (ext === "tar.gz") { + await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin }) + } + + await fs.rm(tempPath, { force: true }) + + bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : "")) + + if (!(await Filesystem.exists(bin))) { + return + } + + if (platform !== "win32") { + await fs.chmod(bin, 0o755).catch(() => {}) + } + } + + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} + +export const DockerfileLS: Info = { + id: "dockerfile", + extensions: [".dockerfile", "Dockerfile"], + root: async (_file, ctx) => ctx.directory, + async spawn(root, _ctx, flags) { + let binary = which("docker-langserver") + const args: string[] = [] + if (!binary) { + if (flags.disableLspDownload) return + const resolved = await Npm.which("dockerfile-language-server-nodejs") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + } + }, +} + +export const Gleam: Info = { + id: "gleam", + extensions: [".gleam"], + root: NearestRoot(["gleam.toml"]), + async spawn(root) { + const gleam = which("gleam") + if (!gleam) { + return + } + return { + process: spawn(gleam, ["lsp"], { + cwd: root, + }), + } + }, +} + +export const Clojure: Info = { + id: "clojure-lsp", + extensions: [".clj", ".cljs", ".cljc", ".edn"], + root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]), + async spawn(root) { + let bin = which("clojure-lsp") + if (!bin && process.platform === "win32") { + bin = which("clojure-lsp.exe") + } + if (!bin) { + return + } + return { + process: spawn(bin, ["listen"], { + cwd: root, + }), + } + }, +} + +export const Nixd: Info = { + id: "nixd", + extensions: [".nix"], + root: async (file, ctx) => { + // First, look for flake.nix - the most reliable Nix project root indicator + const flakeRoot = await NearestRoot(["flake.nix"])(file, ctx) + if (flakeRoot && flakeRoot !== ctx.directory) return flakeRoot + + // If no flake.nix, fall back to git repository root + if (ctx.worktree && ctx.worktree !== ctx.directory) return ctx.worktree + + // Finally, use the instance directory as fallback + return ctx.directory + }, + async spawn(root) { + const nixd = which("nixd") + if (!nixd) { + return + } + return { + process: spawn(nixd, [], { + cwd: root, + env: { + ...process.env, + }, + }), + } + }, +} + +export const Tinymist: Info = { + id: "tinymist", + extensions: [".typ", ".typc"], + root: NearestRoot(["typst.toml"]), + async spawn(root, _ctx, flags) { + let bin = which("tinymist") + + if (!bin) { + if (flags.disableLspDownload) return + + const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest") + if (!response.ok) { + return + } + + const release = (await response.json()) as { + tag_name?: string + assets?: { name?: string; browser_download_url?: string }[] + } + + const platform = process.platform + const arch = process.arch + + const tinymistArch = arch === "arm64" ? "aarch64" : "x86_64" + let tinymistPlatform: string + let ext: string + + if (platform === "darwin") { + tinymistPlatform = "apple-darwin" + ext = "tar.gz" + } else if (platform === "win32") { + tinymistPlatform = "pc-windows-msvc" + ext = "zip" + } else { + tinymistPlatform = "unknown-linux-gnu" + ext = "tar.gz" + } + + const assetName = `tinymist-${tinymistArch}-${tinymistPlatform}.${ext}` + + const assets = release.assets ?? [] + const asset = assets.find((a) => a.name === assetName) + if (!asset?.browser_download_url) { + return + } + + const downloadResponse = await fetch(asset.browser_download_url) + if (!downloadResponse.ok) { + return + } + + const tempPath = path.join(Global.Path.bin, assetName) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + + if (ext === "zip") { + const ok = await Archive.extractZip(tempPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + return false + }) + if (!ok) return + } else { + await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin }) + } + + await fs.rm(tempPath, { force: true }) + + bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : "")) + + if (!(await Filesystem.exists(bin))) { + return + } + + if (platform !== "win32") { + await fs.chmod(bin, 0o755).catch(() => {}) + } + } + + return { + process: spawn(bin, { cwd: root }), + } + }, +} + +export const HLS: Info = { + id: "haskell-language-server", + extensions: [".hs", ".lhs"], + root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]), + async spawn(root) { + const bin = which("haskell-language-server-wrapper") + if (!bin) { + return + } + return { + process: spawn(bin, ["--lsp"], { + cwd: root, + }), + } + }, +} + +export const JuliaLS: Info = { + id: "julials", + extensions: [".jl"], + root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]), + async spawn(root) { + const julia = which("julia") + if (!julia) { + return + } + return { + process: spawn(julia, ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], { + cwd: root, + }), + } + }, +} diff --git a/packages/opencode/src/markdown.d.ts b/packages/opencode/src/markdown.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb3e3b92d663efa9ff0c4b1ff21f15a33e7b67ab --- /dev/null +++ b/packages/opencode/src/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string + export default content +} diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..808aa3029625ac13c9bf08d5077979b9d617d636 --- /dev/null +++ b/packages/opencode/src/mcp/auth.ts @@ -0,0 +1,163 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import path from "path" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { Global } from "@opencode-ai/core/global" +import { Effect, Layer, Context, Option, Schema } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" + +export const Tokens = Schema.Struct({ + accessToken: Schema.mutableKey(Schema.String), + refreshToken: Schema.mutableKey(Schema.optional(Schema.String)), + expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), + scope: Schema.mutableKey(Schema.optional(Schema.String)), +}) +export type Tokens = Schema.Schema.Type + +export const ClientInfo = Schema.Struct({ + clientId: Schema.mutableKey(Schema.String), + clientSecret: Schema.mutableKey(Schema.optional(Schema.String)), + clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)), + clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), +}) +export type ClientInfo = Schema.Schema.Type + +export const Entry = Schema.Struct({ + tokens: Schema.mutableKey(Schema.optional(Tokens)), + clientInfo: Schema.mutableKey(Schema.optional(ClientInfo)), + codeVerifier: Schema.mutableKey(Schema.optional(Schema.String)), + oauthState: Schema.mutableKey(Schema.optional(Schema.String)), + serverUrl: Schema.mutableKey(Schema.optional(Schema.String)), +}) +export type Entry = Schema.Schema.Type + +const decodeAuthData = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry)) +type AuthData = Record + +const filepath = path.join(Global.Path.data, "mcp-auth.json") +const lockKey = `mcp-auth:${filepath}` + +export interface Interface { + readonly all: () => Effect.Effect> + readonly get: (mcpName: string) => Effect.Effect + readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect + readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect + readonly remove: (mcpName: string) => Effect.Effect + readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect + readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect + readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect + readonly clearCodeVerifier: (mcpName: string) => Effect.Effect + readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect + readonly getOAuthState: (mcpName: string) => Effect.Effect + readonly clearOAuthState: (mcpName: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/McpAuth") {} + +export const use = serviceUse(Service) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const flock = yield* EffectFlock.Service + + const read = Effect.fn("McpAuth.read")(function* () { + return yield* fs.readJson(filepath).pipe( + Effect.map((data): AuthData => Option.getOrElse(decodeAuthData(data), () => ({}) as AuthData) as AuthData), + Effect.catch(() => Effect.succeed({} as AuthData)), + ) + }) + + const all = Effect.fn("McpAuth.all")(function* () { + return yield* read().pipe(flock.withLock(lockKey), Effect.orDie) + }) + + const mutate = Effect.fn("McpAuth.mutate")(function* (update: (data: AuthData) => AuthData | undefined) { + yield* Effect.gen(function* () { + const next = update(yield* read()) + if (!next) return + yield* fs.writeJson(filepath, next, 0o600).pipe(Effect.orDie) + }).pipe(flock.withLock(lockKey), Effect.orDie) + }) + + const get = Effect.fn("McpAuth.get")(function* (mcpName: string) { + const data = yield* all() + return data[mcpName] + }) + + const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) { + const entry = yield* get(mcpName) + if (!entry) return undefined + if (!entry.serverUrl) return undefined + if (entry.serverUrl !== serverUrl) return undefined + return entry + }) + + const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) { + yield* mutate((data) => ({ + ...data, + [mcpName]: serverUrl ? { ...entry, serverUrl } : entry, + })) + }) + + const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) { + yield* mutate((data) => { + const next = { ...data } + delete next[mcpName] + return next + }) + }) + + const updateField = (field: K, spanName: string) => + Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable, serverUrl?: string) { + yield* mutate((data) => { + const entry = data[mcpName] ?? {} + entry[field] = value + if (serverUrl) entry.serverUrl = serverUrl + return { ...data, [mcpName]: entry } + }) + }) + + const clearField = (field: keyof Entry, spanName: string) => + Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) { + yield* mutate((data) => { + const entry = data[mcpName] + if (!entry) return undefined + delete entry[field] + return { ...data, [mcpName]: entry } + }) + }) + + const updateTokens = updateField("tokens", "updateTokens") + const updateClientInfo = updateField("clientInfo", "updateClientInfo") + const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier") + const updateOAuthState = updateField("oauthState", "updateOAuthState") + const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier") + const clearOAuthState = clearField("oauthState", "clearOAuthState") + + const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) { + const entry = yield* get(mcpName) + return entry?.oauthState + }) + + return Service.of({ + all, + get, + getForUrl, + set, + remove, + updateTokens, + updateClientInfo, + updateCodeVerifier, + clearCodeVerifier, + updateOAuthState, + getOAuthState, + clearOAuthState, + }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EffectFlock.node] }) + +export * as McpAuth from "./auth" diff --git a/packages/opencode/src/mcp/browser.ts b/packages/opencode/src/mcp/browser.ts new file mode 100644 index 0000000000000000000000000000000000000000..5760d8cbf9a04f8dbae306ea478aa5b2bf50ecc0 --- /dev/null +++ b/packages/opencode/src/mcp/browser.ts @@ -0,0 +1,37 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Context, Effect, Layer } from "effect" +import open from "open" + +export interface Interface { + readonly open: (url: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/McpBrowser") {} + +const layer = Layer.succeed( + Service, + Service.of({ + open: Effect.fn("McpBrowser.open")(function* (url: string) { + const subprocess = yield* Effect.tryPromise({ + try: () => open(url), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) + yield* Effect.callback((resume) => { + const timer = setTimeout(() => resume(Effect.void), 500) + subprocess.on("error", (error) => { + clearTimeout(timer) + resume(Effect.fail(error)) + }) + subprocess.on("exit", (code) => { + if (code === null || code === 0) return + clearTimeout(timer) + resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`))) + }) + }) + }), + }), +) + +export const node = LayerNode.make({ service: Service, layer, deps: [] }) + +export * as McpBrowser from "./browser" diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f985eeb94dc1582eb4aeb50980d92adb04fc79b --- /dev/null +++ b/packages/opencode/src/mcp/catalog.ts @@ -0,0 +1,170 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { + CallToolResultSchema, + ListToolsResultSchema, + ToolSchema, + type Tool as MCPToolDef, +} from "@modelcontextprotocol/sdk/types.js" +import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai" +import { Effect } from "effect" + +const DEFAULT_TIMEOUT = 30_000 +const MAX_LIST_PAGES = 1_000 + +const TolerantListToolsResultSchema = ListToolsResultSchema.extend({ + tools: ToolSchema.omit({ outputSchema: true }).array(), +}) + +export async function paginate( + list: (cursor?: string) => Promise, + items: (result: R) => T[], +) { + const result: T[] = [] + const cursors = new Set() + let cursor: string | undefined + + for (let page = 0; page < MAX_LIST_PAGES; page++) { + const page = await list(cursor) + result.push(...items(page)) + if (page.nextCursor === undefined) return result + if (cursors.has(page.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${page.nextCursor}`) + cursors.add(page.nextCursor) + cursor = page.nextCursor + } + + throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`) +} + +export function defs(client: Client, timeout?: number) { + return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void)) +} + +export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: number): Tool { + const inputSchema: JSONSchema7 = { + ...(mcpTool.inputSchema as JSONSchema7), + type: "object", + properties: (mcpTool.inputSchema.properties ?? {}) as JSONSchema7["properties"], + additionalProperties: false, + } + + return dynamicTool({ + description: mcpTool.description ?? "", + inputSchema: jsonSchema(inputSchema), + execute: async (args: unknown, options) => { + const result = await client.callTool( + { + name: mcpTool.name, + arguments: (args || {}) as Record, + }, + CallToolResultSchema, + { + resetTimeoutOnProgress: true, + signal: options.abortSignal, + timeout, + // The MCP SDK only sends a progress token when this hook is present, enabling timeout resets. + onprogress: () => {}, + }, + ) + if (result.isError) + throw new Error( + result.content + .flatMap((item) => (item.type === "text" ? [item.text] : [])) + .filter((text) => text.trim()) + .join("\n\n") || "MCP tool returned an error", + ) + if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null) + return result + return { + ...result, + content: [{ type: "text" as const, text: JSON.stringify(result.structuredContent) }], + } + }, + }) +} + +export function fetch( + clientName: string, + client: Client, + list: (client: Client) => Promise, + label: string, + key?: (item: T) => string, +) { + return Effect.tryPromise({ + try: () => list(client), + catch: (error) => error, + }).pipe( + Effect.tapError((error) => + Effect.logWarning(`failed to get ${label}`, { + clientName, + error: error instanceof Error ? error.message : String(error), + }), + ), + Effect.map((items) => { + const sanitizedClient = sanitize(clientName) + // Escape both the separator and escape marker so `server:uri` keys remain unambiguous. + const resourceClient = clientName.replaceAll("%", "%25").replaceAll(":", "%3A") + return Object.fromEntries( + items.map((item) => [ + key ? resourceClient + ":" + key(item) : sanitizedClient + ":" + sanitize(item.name), + { ...item, client: clientName }, + ]), + ) + }), + Effect.orElseSucceed(() => undefined), + ) +} + +export const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_") + +export const toolName = (clientName: string, name: string) => sanitize(clientName) + "_" + sanitize(name) + +export function prompts(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.prompts) return Promise.resolve([]) + return paginate( + (cursor) => client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout }), + (result) => result.prompts, + ) +} + +export function resources(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return Promise.resolve([]) + return paginate( + (cursor) => client.listResources(cursor === undefined ? undefined : { cursor }, { timeout }), + (result) => result.resources, + ) +} + +export function resourceTemplates(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return Promise.resolve([]) + return paginate( + (cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }), + (result) => result.resourceTemplates, + ) +} + +function listTools(client: Client, timeout: number) { + return Effect.tryPromise({ + try: () => + paginate( + async (cursor) => { + const params = cursor === undefined ? undefined : { cursor } + try { + return await client.listTools(params, { timeout }) + } catch (error) { + if (!(error instanceof Error) || !isOutputSchemaValidationError(error)) throw error + return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { timeout }) + } + }, + (result) => result.tools, + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) +} + +function isOutputSchemaValidationError(error: Error) { + return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test( + error.message, + ) +} + +export * as McpCatalog from "./catalog" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..05f12fa2ee4526e8b584fb367479cc55638cc7e6 --- /dev/null +++ b/packages/opencode/src/mcp/index.ts @@ -0,0 +1,1004 @@ +import path from "node:path" +import { pathToFileURL } from "node:url" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" +import { + ListRootsRequestSchema, + type LoggingMessageNotification, + LoggingMessageNotificationSchema, + type Tool as MCPToolDef, + ToolListChangedNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js" +import { Config } from "@/config/config" +import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" +import { NamedError } from "@opencode-ai/core/util/error" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { withTimeout } from "@/util/timeout" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { McpOAuthPendingProvider, McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider" +import { McpOAuthCallback } from "./oauth-callback" +import { McpAuth } from "./auth" +import { EventV2Bridge } from "@/event-v2-bridge" +import { TuiEvent } from "@/server/tui-event" +import { Cause, Effect, Exit, Layer, Context, Schema, Stream } from "effect" +import { EffectBridge } from "@/effect/bridge" +import { InstanceState } from "@/effect/instance-state" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { McpCatalog } from "./catalog" +import { McpEvent } from "@opencode-ai/schema/mcp-event" +import { McpBrowser } from "./browser" + +const DEFAULT_TIMEOUT = 30_000 +const CLIENT_OPTIONS = { + capabilities: { + // https://github.com/anomalyco/opencode/issues/11948 + // sampling: {}, + // https://github.com/anomalyco/opencode/issues/23066 + // elicitation: {}, + // https://github.com/anomalyco/opencode/issues/2308 + roots: {}, + // https://github.com/anomalyco/opencode/issues/28567 + // tasks: {}, + }, +} satisfies ClientOptions + +export const Resource = Schema.Struct({ + name: Schema.String, + uri: Schema.String, + description: Schema.optional(Schema.String), + mimeType: Schema.optional(Schema.String), + client: Schema.String, +}).annotate({ identifier: "McpResource" }) +export type Resource = Schema.Schema.Type + +export const ToolsChanged = McpEvent.ToolsChanged + +export const BrowserOpenFailed = McpEvent.BrowserOpenFailed + +export const Failed = NamedError.create("MCPFailed", { + name: Schema.String, +}) + +export class NotFoundError extends Schema.TaggedErrorClass()("MCP.NotFoundError", { + name: Schema.String, +}) {} + +type MCPClient = Client + +function createClient(directory: string) { + const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS) + client.setRequestHandler(ListRootsRequestSchema, () => + Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }), + ) + return client +} + +const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({ + identifier: "MCPStatusConnected", +}) +const StatusDisabled = Schema.Struct({ status: Schema.Literal("disabled") }).annotate({ + identifier: "MCPStatusDisabled", +}) +const StatusFailed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String }).annotate({ + identifier: "MCPStatusFailed", +}) +const StatusNeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({ + identifier: "MCPStatusNeedsAuth", +}) +const StatusNeedsClientRegistration = Schema.Struct({ + status: Schema.Literal("needs_client_registration"), + error: Schema.String, +}).annotate({ identifier: "MCPStatusNeedsClientRegistration" }) + +export const Status = Schema.Union([ + StatusConnected, + StatusDisabled, + StatusFailed, + StatusNeedsAuth, + StatusNeedsClientRegistration, +]).annotate({ identifier: "MCPStatus", discriminator: "status" }) +export type Status = Schema.Schema.Type + +// Store transports for OAuth servers to allow finishing auth +type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport +const pendingOAuthTransports = new Map() + +// Prompt cache types +type PromptInfo = Awaited>["prompts"][number] +type ResourceInfo = Awaited>["resources"][number] +type ResourceTemplateInfo = Awaited>["resourceTemplates"][number] +type McpEntry = NonNullable[string] + +function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info { + return typeof entry === "object" && entry !== null && "type" in entry +} + +function remoteURL(value: string) { + if (URL.canParse(value)) return new URL(value) +} + +interface CreateResult { + mcpClient?: MCPClient + status: Status + defs?: MCPToolDef[] + instructions?: string +} + +interface AuthResult { + authorizationUrl: string + oauthState: string + client?: MCPClient +} + +// --- Effect Service --- + +interface State { + config: Record + status: Record + clients: Record + defs: Record + instructions: Record +} + +export interface ServerInstructions { + name: string + instructions: string + tools: string[] +} + +/** An MCP tool in its native shape; consumers adapt it to their own tool format. */ +export interface McpTool { + /** Shared cached definition; consumers must copy rather than mutate it. */ + readonly def: MCPToolDef + readonly client: MCPClient + readonly timeout?: number +} + +export interface Interface { + readonly status: () => Effect.Effect> + readonly clients: () => Effect.Effect> + readonly instructions: () => Effect.Effect + readonly tools: () => Effect.Effect> + readonly prompts: () => Effect.Effect> + readonly resources: (clientName?: string) => Effect.Effect> + readonly resourceTemplates: ( + clientName?: string, + ) => Effect.Effect> + readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record | Status }> + readonly connect: (name: string) => Effect.Effect + readonly disconnect: (name: string) => Effect.Effect + readonly getPrompt: ( + clientName: string, + name: string, + args?: Record, + ) => Effect.Effect> | undefined> + readonly readResource: ( + clientName: string, + resourceUri: string, + ) => Effect.Effect> | undefined> + readonly startAuth: ( + mcpName: string, + ) => Effect.Effect<{ authorizationUrl: string; oauthState: string }, NotFoundError> + readonly authenticate: ( + mcpName: string, + onAuthorization?: (authorizationUrl: string) => void, + ) => Effect.Effect + readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect + readonly removeAuth: (mcpName: string) => Effect.Effect + readonly supportsOAuth: (mcpName: string) => Effect.Effect + readonly hasStoredTokens: (mcpName: string) => Effect.Effect + readonly getAuthStatus: (mcpName: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/MCP") {} + +export const use = serviceUse(Service) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const auth = yield* McpAuth.Service + const events = yield* EventV2Bridge.Service + const browser = yield* McpBrowser.Service + + type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport + + /** + * Connect a client via the given transport with resource safety: + * on failure the transport is closed; on success the caller owns it. + */ + const connectTransport = Effect.fn("MCP.connectTransport")(function* (transport: Transport, timeout: number) { + const directory = yield* InstanceState.directory + return yield* Effect.acquireUseRelease( + Effect.succeed(transport), + (t) => + Effect.tryPromise({ + try: () => { + const client = createClient(directory) + return withTimeout(client.connect(t), timeout).then(() => client) + }, + catch: (e) => (e instanceof Error ? e : new Error(String(e))), + }), + (t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void), + ) + }) + + const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } } + + const connectRemote = Effect.fn("MCP.connectRemote")(function* ( + key: string, + mcp: ConfigMCPV1.Info & { type: "remote" }, + ) { + const oauthDisabled = mcp.oauth === false + const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined + const url = remoteURL(mcp.url) + if (!url) { + return { + client: undefined as MCPClient | undefined, + status: { status: "failed" as const, error: `Invalid MCP URL for "${key}"` }, + } + } + let authProvider: McpOAuthProvider | undefined + + if (!oauthDisabled) { + authProvider = new McpOAuthProvider( + key, + mcp.url, + { + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, + callbackPort: oauthConfig?.callbackPort, + redirectUri: oauthConfig?.redirectUri, + }, + { + onRedirect: async () => {}, + }, + auth, + ) + } + + const transports: Array<{ name: string; transport: TransportWithAuth }> = [ + { + name: "StreamableHTTP", + transport: new StreamableHTTPClientTransport(url, { + authProvider, + requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + }), + }, + { + name: "SSE", + transport: new SSEClientTransport(url, { + authProvider, + requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + }), + }, + ] + + const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT + let lastStatus: Status | undefined + + for (const { name, transport } of transports) { + const result = yield* connectTransport(transport, connectTimeout).pipe( + Effect.map((client) => ({ client, transportName: name })), + Effect.catch((error) => { + const lastError = error instanceof Error ? error : new Error(String(error)) + const isAuthError = + error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth")) + + if (isAuthError) { + if (lastError.message.includes("registration") || lastError.message.includes("client_id")) { + lastStatus = { + status: "needs_client_registration" as const, + error: "Server does not support dynamic client registration. Please provide clientId in config.", + } + return events + .publish(TuiEvent.ToastShow, { + title: "MCP Authentication Required", + message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`, + variant: "warning", + duration: 8000, + }) + .pipe(Effect.ignore, Effect.as(undefined)) + } else { + pendingOAuthTransports.set(key, { transport }) + lastStatus = { status: "needs_auth" as const } + return events + .publish(TuiEvent.ToastShow, { + title: "MCP Authentication Required", + message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`, + variant: "warning", + duration: 8000, + }) + .pipe(Effect.ignore, Effect.as(undefined)) + } + } + + lastStatus = { status: "failed" as const, error: lastError.message } + return Effect.void + }), + ) + if (result) return { client: result.client, status: { status: "connected" } as Status } + // If this was an auth error, stop trying other transports + if (lastStatus?.status === "needs_auth" || lastStatus?.status === "needs_client_registration") break + } + + return { + client: undefined as MCPClient | undefined, + status: (lastStatus ?? { status: "failed", error: "Unknown error" }) as Status, + } + }) + + const connectLocal = Effect.fn("MCP.connectLocal")(function* ( + key: string, + mcp: ConfigMCPV1.Info & { type: "local" }, + ) { + const [cmd, ...args] = mcp.command + const baseDir = yield* InstanceState.directory + const cwd = mcp.cwd ? path.resolve(baseDir, mcp.cwd) : baseDir + const transport = new StdioClientTransport({ + stderr: "pipe", + command: cmd, + args, + cwd, + env: { + ...process.env, + ...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}), + ...mcp.environment, + }, + }) + + const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT + return yield* connectTransport(transport, connectTimeout).pipe( + Effect.map((client): { client: MCPClient | undefined; status: Status } => ({ + client, + status: { status: "connected" }, + })), + Effect.catch((error): Effect.Effect<{ client: MCPClient | undefined; status: Status }> => { + const msg = error instanceof Error ? error.message : String(error) + return Effect.succeed({ client: undefined, status: { status: "failed", error: msg } }) + }), + ) + }) + + const create = Effect.fn("MCP.create")( + function* (key: string, mcp: ConfigMCPV1.Info) { + if (mcp.enabled === false) { + return DISABLED_RESULT + } + + const { client: mcpClient, status } = + mcp.type === "remote" + ? yield* connectRemote(key, mcp as ConfigMCPV1.Info & { type: "remote" }) + : yield* connectLocal(key, mcp as ConfigMCPV1.Info & { type: "local" }) + + if (!mcpClient) { + if (status.status !== "connected" && status.status !== "disabled") { + yield* Effect.logWarning("server unavailable", { key, type: mcp.type, status: status.status }) + } + return { status } satisfies CreateResult + } + + return yield* Effect.gen(function* () { + const listed = mcpClient.getServerCapabilities()?.tools ? yield* McpCatalog.defs(mcpClient, mcp.timeout) : [] + if (!listed) { + return yield* Effect.fail(new Error("Failed to get tools")) + } + return { + mcpClient, + status, + defs: listed, + instructions: mcpClient.getInstructions()?.trim(), + } satisfies CreateResult + }).pipe( + Effect.catchCause((cause) => + Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore, Effect.andThen(Effect.failCause(cause))), + ), + ) + }, + Effect.map((result): CreateResult => result), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt + const error = Cause.squash(cause) + return Effect.succeed({ + status: { status: "failed", error: error instanceof Error ? error.message : String(error) }, + }) + }), + ) + const cfgSvc = yield* Config.Service + + const descendants = Effect.fnUntraced( + function* (pid: number) { + if (process.platform === "win32") return [] as number[] + const pids: number[] = [] + const queue = [pid] + for (let index = 0; index < queue.length; index++) { + const current = queue[index] + const handle = yield* spawner.spawn(ChildProcess.make("pgrep", ["-P", String(current)], { stdin: "ignore" })) + const text = yield* Stream.mkString(Stream.decodeText(handle.stdout)) + yield* handle.exitCode + for (const tok of text.split("\n")) { + const cpid = parseInt(tok, 10) + if (!isNaN(cpid) && !pids.includes(cpid)) { + pids.push(cpid) + queue.push(cpid) + } + } + } + return pids + }, + Effect.scoped, + Effect.catch(() => Effect.succeed([] as number[])), + ) + + function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) { + client.onclose = () => { + if (s.clients[name] !== client) return + delete s.clients[name] + delete s.defs[name] + delete s.instructions[name] + s.status[name] = { status: "failed", error: "Connection closed" } + bridge.fork( + Effect.logWarning("MCP connection closed", { server: name }).pipe( + Effect.andThen(events.publish(ToolsChanged, { server: name })), + Effect.ignore, + ), + ) + } + + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => + bridge.promise(serverLog(name, notification.params)), + ) + + if (!client.getServerCapabilities()?.tools) return + client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + + const listed = await bridge.promise(McpCatalog.defs(client, timeout)) + if (!listed) return + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + + s.defs[name] = listed + await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) + }) + } + + function serverLog(name: string, params: LoggingMessageNotification["params"]) { + const fields = { server: name, logger: params.logger, level: params.level, data: params.data } + switch (params.level) { + case "debug": + return Effect.logDebug("MCP server log", fields) + case "info": + case "notice": + return Effect.logInfo("MCP server log", fields) + case "warning": + return Effect.logWarning("MCP server log", fields) + case "error": + case "critical": + case "alert": + case "emergency": + return Effect.logError("MCP server log", fields) + } + } + + const state = yield* InstanceState.make( + Effect.fn("MCP.state")(function* () { + const cfg = yield* cfgSvc.get() + const bridge = yield* EffectBridge.make() + const config = cfg.mcp ?? {} + const s: State = { + config: {}, + status: {}, + clients: {}, + defs: {}, + instructions: {}, + } + + yield* Effect.forEach( + Object.entries(config), + ([key, mcp]) => + Effect.gen(function* () { + if (!isMcpConfigured(mcp)) { + yield* Effect.logError("Ignoring MCP config entry without type", { key }) + return + } + + if (mcp.enabled === false) { + s.status[key] = { status: "disabled" } + return + } + + const result = yield* create(key, mcp) + s.status[key] = result.status + if (result.mcpClient) { + s.clients[key] = result.mcpClient + s.defs[key] = result.defs! + if (result.instructions) s.instructions[key] = result.instructions + watch(s, key, result.mcpClient, bridge, mcp.timeout) + } + }), + { concurrency: "unbounded" }, + ) + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const clients = Object.values(s.clients) + s.clients = {} + s.defs = {} + s.instructions = {} + yield* Effect.forEach( + clients, + (client) => + Effect.gen(function* () { + const pid = client.transport instanceof StdioClientTransport ? client.transport.pid : null + if (typeof pid === "number") { + const pids = yield* descendants(pid) + for (const dpid of pids) { + try { + process.kill(dpid, "SIGTERM") + } catch {} + } + } + yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore) + }), + { concurrency: "unbounded" }, + ) + pendingOAuthTransports.clear() + }), + ) + + return s + }), + ) + + function closeClient(s: State, name: string) { + const client = s.clients[name] + delete s.clients[name] + delete s.defs[name] + delete s.instructions[name] + if (!client) return Effect.void + return Effect.tryPromise(() => client.close()).pipe(Effect.ignore) + } + + const storeClient = Effect.fnUntraced(function* ( + s: State, + name: string, + client: MCPClient, + listed: MCPToolDef[], + instructions: string | undefined, + timeout?: number, + ) { + const bridge = yield* EffectBridge.make() + const previous = s.clients[name] + s.status[name] = { status: "connected" } + s.clients[name] = client + s.defs[name] = listed + if (instructions) s.instructions[name] = instructions + else delete s.instructions[name] + watch(s, name, client, bridge, timeout) + if (previous) yield* Effect.tryPromise(() => previous.close()).pipe(Effect.ignore) + return s.status[name] + }) + + const status = Effect.fn("MCP.status")(function* () { + const s = yield* InstanceState.get(state) + + const cfg = yield* cfgSvc.get() + const config = cfg.mcp ?? {} + const result: Record = {} + + for (const [key, mcp] of Object.entries(config)) { + if (!isMcpConfigured(mcp)) continue + result[key] = s.status[key] ?? { status: "disabled" } + } + + for (const key of Object.keys(s.config)) { + result[key] = s.status[key] ?? { status: "disabled" } + } + + return result + }) + + const clients = Effect.fn("MCP.clients")(function* () { + const s = yield* InstanceState.get(state) + return s.clients + }) + + const instructions = Effect.fn("MCP.instructions")(function* () { + const s = yield* InstanceState.get(state) + return Object.entries(s.instructions) + .filter(([name]) => s.status[name]?.status === "connected") + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, item]) => ({ + name, + instructions: item, + tools: (s.defs[name] ?? []).map((tool) => McpCatalog.toolName(name, tool.name)), + })) + }) + + const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCPV1.Info) { + const s = yield* InstanceState.get(state) + const result = yield* create(name, mcp) + + s.status[name] = result.status + if (!result.mcpClient) { + yield* closeClient(s, name) + delete s.clients[name] + return result.status + } + + return yield* storeClient(s, name, result.mcpClient, result.defs!, result.instructions, mcp.timeout) + }) + + const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCPV1.Info) { + const s = yield* InstanceState.get(state) + s.config[name] = mcp + yield* createAndStore(name, mcp) + return { status: s.status } + }) + + const connect = Effect.fn("MCP.connect")(function* (name: string) { + const mcp = yield* requireMcpConfig(name) + yield* createAndStore(name, { ...mcp, enabled: true }) + }) + + const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) { + yield* requireMcpConfig(name) + const s = yield* InstanceState.get(state) + yield* closeClient(s, name) + delete s.clients[name] + s.status[name] = { status: "disabled" } + }) + + function requestTimeout(s: State, name: string, configured: McpEntry | undefined, fallback?: number) { + const staticTimeout = configured && isMcpConfigured(configured) ? configured.timeout : undefined + return s.config[name]?.timeout ?? staticTimeout ?? fallback + } + + const tools = Effect.fn("MCP.tools")(function* () { + const result: Record = {} + const s = yield* InstanceState.get(state) + + const cfg = yield* cfgSvc.get() + const config = cfg.mcp ?? {} + const defaultTimeout = cfg.experimental?.mcp_timeout + + for (const [clientName, client] of Object.entries(s.clients)) { + if (s.status[clientName]?.status !== "connected") continue + const mcpConfig = config[clientName] + const listed = s.defs[clientName] + if (!listed) { + yield* Effect.logWarning("missing cached tools for connected server", { clientName }) + continue + } + const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) + for (const def of listed) { + result[McpCatalog.toolName(clientName, def.name)] = { def, client, timeout } + } + } + return result + }) + + function collectFromConnected( + s: State, + listFn: (c: Client, timeout?: number) => Promise, + label: string, + key?: (item: T) => string, + targetClientName?: string, + ) { + return Effect.gen(function* () { + const cfg = yield* cfgSvc.get() + return yield* Effect.forEach( + Object.entries(s.clients).filter( + ([name]) => s.status[name]?.status === "connected" && (!targetClientName || name === targetClientName), + ), + ([clientName, client]) => + McpCatalog.fetch( + clientName, + client, + (c) => listFn(c, requestTimeout(s, clientName, cfg.mcp?.[clientName], cfg.experimental?.mcp_timeout)), + label, + key, + ).pipe(Effect.map((items) => Object.entries(items ?? {}))), + { concurrency: "unbounded" }, + ).pipe(Effect.map((results) => Object.fromEntries(results.flat()))) + }) + } + + const prompts = Effect.fn("MCP.prompts")(function* () { + return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.prompts, "prompts") + }) + + const resources = Effect.fn("MCP.resources")(function* (clientName?: string) { + return yield* collectFromConnected( + yield* InstanceState.get(state), + McpCatalog.resources, + "resources", + (resource) => resource.uri, + clientName, + ) + }) + + const resourceTemplates = Effect.fn("MCP.resourceTemplates")(function* (clientName?: string) { + return yield* collectFromConnected( + yield* InstanceState.get(state), + McpCatalog.resourceTemplates, + "resource templates", + (template) => template.uriTemplate, + clientName, + ) + }) + + const withClient = Effect.fnUntraced(function* ( + clientName: string, + fn: (client: MCPClient, timeout?: number) => Promise, + label: string, + meta?: Record, + ) { + const s = yield* InstanceState.get(state) + const client = s.clients[clientName] + if (!client) { + yield* Effect.logWarning(`client not found for ${label}`, { clientName }) + return undefined + } + const cfg = yield* cfgSvc.get() + return yield* Effect.tryPromise({ + try: () => fn(client, requestTimeout(s, clientName, cfg.mcp?.[clientName], cfg.experimental?.mcp_timeout)), + catch: (error) => error, + }).pipe( + Effect.tapError((error) => + Effect.logError(`failed to ${label}`, { + clientName, + ...meta, + error: error instanceof Error ? error.message : String(error), + }), + ), + Effect.orElseSucceed(() => undefined), + ) + }) + + const getPrompt = Effect.fn("MCP.getPrompt")(function* ( + clientName: string, + name: string, + args?: Record, + ) { + return yield* withClient( + clientName, + (client, timeout) => client.getPrompt({ name, arguments: args }, { timeout }), + "getPrompt", + { promptName: name }, + ) + }) + + const readResource = Effect.fn("MCP.readResource")(function* (clientName: string, resourceUri: string) { + return yield* withClient( + clientName, + (client, timeout) => client.readResource({ uri: resourceUri }, { timeout }), + "readResource", + { resourceUri }, + ) + }) + + const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) { + const s = yield* InstanceState.get(state) + if (s.config[mcpName]) return s.config[mcpName] + + const cfg = yield* cfgSvc.get() + const mcpConfig = cfg.mcp?.[mcpName] + if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined + return mcpConfig + }) + + const requireMcpConfig = Effect.fnUntraced(function* (mcpName: string) { + const mcpConfig = yield* getMcpConfig(mcpName) + if (!mcpConfig) return yield* new NotFoundError({ name: mcpName }) + return mcpConfig + }) + + const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) { + const mcpConfig = yield* requireMcpConfig(mcpName) + if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`) + if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) + const url = remoteURL(mcpConfig.url) + if (!url) throw new Error(`Invalid MCP URL for "${mcpName}"`) + + // OAuth config is optional - if not provided, we'll use auto-discovery + const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined + + // Resolve effective redirect URI: explicit redirectUri > callbackPort shorthand > default + const effectiveRedirectUri = + oauthConfig?.redirectUri ?? + (oauthConfig?.callbackPort ? `http://127.0.0.1:${oauthConfig.callbackPort}${OAUTH_CALLBACK_PATH}` : undefined) + + // Start the callback server with custom redirectUri if configured + yield* Effect.promise(() => McpOAuthCallback.ensureRunning(effectiveRedirectUri)) + + const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32))) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + yield* auth.updateOAuthState(mcpName, oauthState) + let capturedUrl: URL | undefined + const authProvider = new McpOAuthPendingProvider( + mcpName, + mcpConfig.url, + { + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, + redirectUri: effectiveRedirectUri, + }, + { + onRedirect: async (url) => { + capturedUrl = url + }, + }, + auth, + ) + + const transport = new StreamableHTTPClientTransport(url, { + authProvider, + requestInit: mcpConfig.headers ? { headers: mcpConfig.headers } : undefined, + }) + const directory = yield* InstanceState.directory + + return yield* Effect.tryPromise({ + try: () => { + const client = createClient(directory) + return client.connect(transport).then(async () => { + await authProvider.commit() + return { authorizationUrl: "", oauthState, client } satisfies AuthResult + }) + }, + catch: (error) => error, + }).pipe( + Effect.catch((error) => { + if (error instanceof UnauthorizedError && capturedUrl) { + pendingOAuthTransports.set(mcpName, { transport, provider: authProvider }) + return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult) + } + return Effect.die(error) + }), + ) + }) + + const authenticate = Effect.fn("MCP.authenticate")(function* ( + mcpName: string, + onAuthorization?: (authorizationUrl: string) => void, + ) { + const result = yield* startAuth(mcpName) + if (!result.authorizationUrl) { + const client = "client" in result ? result.client : undefined + const mcpConfig = yield* requireMcpConfig(mcpName).pipe( + Effect.tapError(() => Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)), + ) + + const listed = client + ? client.getServerCapabilities()?.tools + ? yield* McpCatalog.defs(client, mcpConfig.timeout) + : [] + : undefined + if (!client || !listed) { + yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore) + return { status: "failed", error: "Failed to get tools" } satisfies Status + } + + const s = yield* InstanceState.get(state) + yield* auth.clearOAuthState(mcpName) + return yield* storeClient(s, mcpName, client, listed, client.getInstructions()?.trim(), mcpConfig.timeout) + } + + const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName) + onAuthorization?.(result.authorizationUrl) + + yield* browser.open(result.authorizationUrl).pipe( + Effect.catch(() => { + return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) + }), + ) + + const code = yield* Effect.promise(() => callbackPromise) + + const storedState = yield* auth.getOAuthState(mcpName) + if (storedState !== result.oauthState) { + yield* auth.clearOAuthState(mcpName) + throw new Error("OAuth state mismatch - potential CSRF attack") + } + yield* auth.clearOAuthState(mcpName) + return yield* finishAuth(mcpName, code) + }) + + const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) { + yield* requireMcpConfig(mcpName) + const pending = pendingOAuthTransports.get(mcpName) + if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`) + + const error = yield* Effect.tryPromise({ + try: () => pending.transport.finishAuth(authorizationCode), + catch: (error) => error, + }).pipe( + Effect.match({ + onFailure: (error) => (error instanceof Error ? error.message : String(error)), + onSuccess: () => undefined, + }), + ) + + if (error) return { status: "failed", error: `OAuth completion failed: ${error}` } satisfies Status + + yield* Effect.promise(() => pending.provider?.commit() ?? Promise.resolve()) + yield* auth.clearCodeVerifier(mcpName) + pendingOAuthTransports.delete(mcpName) + + const mcpConfig = yield* requireMcpConfig(mcpName) + + return yield* createAndStore(mcpName, { ...mcpConfig, enabled: true }) + }) + + const removeAuth = Effect.fn("MCP.removeAuth")(function* (mcpName: string) { + yield* auth.remove(mcpName) + McpOAuthCallback.cancelPending(mcpName) + pendingOAuthTransports.delete(mcpName) + }) + + const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) { + const mcpConfig = yield* requireMcpConfig(mcpName) + return mcpConfig.type === "remote" && mcpConfig.oauth !== false + }) + + const hasStoredTokens = Effect.fn("MCP.hasStoredTokens")(function* (mcpName: string) { + const entry = yield* auth.get(mcpName) + return !!entry?.tokens + }) + + const getAuthStatus = Effect.fn("MCP.getAuthStatus")(function* (mcpName: string) { + const runtimeConfig = (yield* InstanceState.has(state)) + ? (yield* InstanceState.get(state)).config[mcpName] + : undefined + const mcpConfig = runtimeConfig ?? (yield* cfgSvc.get()).mcp?.[mcpName] + if (!mcpConfig || !isMcpConfigured(mcpConfig) || mcpConfig.type !== "remote") return "not_authenticated" + const entry = yield* auth.getForUrl(mcpName, mcpConfig.url) + if (!entry?.tokens) return "not_authenticated" + if (entry.tokens.expiresAt && entry.tokens.expiresAt < Date.now() / 1000) return "expired" + return "authenticated" + }) + + return Service.of({ + status, + clients, + instructions, + tools, + prompts, + resources, + resourceTemplates, + add, + connect, + disconnect, + getPrompt, + readResource, + startAuth, + authenticate, + finishAuth, + removeAuth, + supportsOAuth, + hasStoredTokens, + getAuthStatus, + }) + }), +) + +export type AuthStatus = "authenticated" | "expired" | "not_authenticated" + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node, McpBrowser.node], +}) + +export * as MCP from "." diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts new file mode 100644 index 0000000000000000000000000000000000000000..84007902b8c08d4d29c34a62a742863d12824440 --- /dev/null +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -0,0 +1,194 @@ +import { createConnection } from "net" +import { createServer } from "http" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" +import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" + +const OAUTH_CALLBACK_HOST = "127.0.0.1" + +// Current callback server configuration (may differ from defaults if custom redirectUri is used) +let currentPort = OAUTH_CALLBACK_PORT +let currentPath = OAUTH_CALLBACK_PATH + +interface PendingAuth { + resolve: (code: string) => void + reject: (error: Error) => void + timeout: ReturnType +} + +let server: ReturnType | undefined +const pendingAuths = new Map() +// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can +// find the right entry in pendingAuths (which is keyed by oauthState). +const mcpNameToState = new Map() + +const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes + +function cleanupStateIndex(oauthState: string) { + for (const [name, state] of mcpNameToState) { + if (state === oauthState) { + mcpNameToState.delete(name) + break + } + } +} + +function stopIfIdle() { + if (pendingAuths.size > 0 || !server) return + + server.close() + server = undefined +} + +function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) { + const url = new URL(req.url || "/", `http://localhost:${currentPort}`) + + if (url.pathname !== currentPath) { + res.writeHead(404) + res.end("Not found") + return + } + + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const error = url.searchParams.get("error") + const errorDescription = url.searchParams.get("error_description") + + // Enforce state parameter presence + if (!state) { + const errorMsg = "Missing required state parameter - potential CSRF attack" + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" })) + return + } + + if (error) { + const errorMsg = errorDescription || error + if (pendingAuths.has(state)) { + const pending = pendingAuths.get(state)! + clearTimeout(pending.timeout) + pendingAuths.delete(state) + cleanupStateIndex(state) + pending.reject(new Error(errorMsg)) + } + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) + res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" })) + stopIfIdle() + return + } + + if (!code) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + res.end(OauthCallbackPage.error("No authorization code provided", { provider: "MCP" })) + return + } + + // Validate state parameter + if (!pendingAuths.has(state)) { + const errorMsg = "Invalid or expired state parameter - potential CSRF attack" + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" })) + return + } + + const pending = pendingAuths.get(state)! + + clearTimeout(pending.timeout) + pendingAuths.delete(state) + cleanupStateIndex(state) + pending.resolve(code) + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) + res.end(OauthCallbackPage.success({ provider: "MCP" })) + stopIfIdle() +} + +export async function ensureRunning(redirectUri?: string): Promise { + // Parse the redirect URI to get port and path (uses defaults if not provided) + const { port, path } = parseRedirectUri(redirectUri) + + // If server is running on a different port/path, stop it first + if (server && (currentPort !== port || currentPath !== path)) { + await stop() + } + + if (server) return + + const running = await isPortInUse(port) + if (running) { + return + } + + currentPort = port + currentPath = path + + server = createServer(handleRequest) + await new Promise((resolve, reject) => { + server!.listen(currentPort, OAUTH_CALLBACK_HOST, () => { + resolve() + }) + server!.on("error", reject) + }) +} + +export function waitForCallback(oauthState: string, mcpName?: string): Promise { + if (mcpName) mcpNameToState.set(mcpName, oauthState) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (pendingAuths.has(oauthState)) { + pendingAuths.delete(oauthState) + if (mcpName) mcpNameToState.delete(mcpName) + reject(new Error("OAuth callback timeout - authorization took too long")) + stopIfIdle() + } + }, CALLBACK_TIMEOUT_MS) + + pendingAuths.set(oauthState, { resolve, reject, timeout }) + }) +} + +export function cancelPending(mcpName: string): void { + // Look up the oauthState for this mcpName via the reverse index + const oauthState = mcpNameToState.get(mcpName) + const key = oauthState ?? mcpName + const pending = pendingAuths.get(key) + if (pending) { + clearTimeout(pending.timeout) + pendingAuths.delete(key) + mcpNameToState.delete(mcpName) + pending.reject(new Error("Authorization cancelled")) + stopIfIdle() + } +} + +export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise { + return new Promise((resolve) => { + const socket = createConnection(port, "127.0.0.1") + socket.on("connect", () => { + socket.destroy() + resolve(true) + }) + socket.on("error", () => { + resolve(false) + }) + }) +} + +export async function stop(): Promise { + if (server) { + await new Promise((resolve) => server!.close(() => resolve())) + server = undefined + } + + for (const [_name, pending] of pendingAuths) { + clearTimeout(pending.timeout) + pending.reject(new Error("OAuth callback server stopped")) + } + pendingAuths.clear() + mcpNameToState.clear() +} + +export function isRunning(): boolean { + return server !== undefined +} + +export * as McpOAuthCallback from "./oauth-callback" diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..596bfe1d551fbcbcbd1837322cabff4568cb7495 --- /dev/null +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -0,0 +1,259 @@ +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" +import type { + OAuthClientMetadata, + OAuthTokens, + OAuthClientInformation, + OAuthClientInformationFull, +} from "@modelcontextprotocol/sdk/shared/auth.js" +import { Effect } from "effect" +import { McpAuth } from "./auth" + +const OAUTH_CALLBACK_PORT = 19876 +const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback" + +export interface McpOAuthConfig { + clientId?: string + clientSecret?: string + scope?: string + callbackPort?: number + redirectUri?: string +} + +export interface McpOAuthCallbacks { + onRedirect: (url: URL) => void | Promise +} + +export class McpOAuthProvider implements OAuthClientProvider { + constructor( + protected mcpName: string, + protected serverUrl: string, + protected config: McpOAuthConfig, + private callbacks: McpOAuthCallbacks, + protected auth: McpAuth.Interface, + ) {} + + get redirectUrl(): string { + if (this.config.redirectUri) { + return this.config.redirectUri + } + const port = this.config.callbackPort ?? OAUTH_CALLBACK_PORT + return `http://127.0.0.1:${port}${OAUTH_CALLBACK_PATH}` + } + + get clientMetadata(): OAuthClientMetadata { + return { + redirect_uris: [this.redirectUrl], + client_name: "OpenCode", + client_uri: "https://opencode.ai", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none", + ...(this.config.scope ? { scope: this.config.scope } : {}), + } + } + + async clientInformation(): Promise { + if (this.config.clientId) { + return { + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + } + } + + // Check stored client info (from dynamic registration) + // Use getForUrl to validate credentials are for the current server URL + const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) + if (entry?.clientInfo) { + // Check if client secret has expired + if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) { + return undefined + } + return { + client_id: entry.clientInfo.clientId, + client_secret: entry.clientInfo.clientSecret, + } + } + + // No client info or URL changed - will trigger dynamic registration + return undefined + } + + async saveClientInformation(info: OAuthClientInformationFull): Promise { + await Effect.runPromise( + this.auth.updateClientInfo( + this.mcpName, + { + clientId: info.client_id, + clientSecret: info.client_secret, + clientIdIssuedAt: info.client_id_issued_at, + clientSecretExpiresAt: info.client_secret_expires_at, + }, + this.serverUrl, + ), + ) + } + + async tokens(): Promise { + // Use getForUrl to validate tokens are for the current server URL + const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) + if (!entry?.tokens) return undefined + + return { + access_token: entry.tokens.accessToken, + token_type: "Bearer", + refresh_token: entry.tokens.refreshToken, + expires_in: entry.tokens.expiresAt + ? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000)) + : undefined, + scope: entry.tokens.scope, + } + } + + async saveTokens(tokens: OAuthTokens): Promise { + await Effect.runPromise( + this.auth.updateTokens( + this.mcpName, + { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined, + scope: tokens.scope, + }, + this.serverUrl, + ), + ) + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + await this.callbacks.onRedirect(authorizationUrl) + } + + async saveCodeVerifier(codeVerifier: string): Promise { + await Effect.runPromise(this.auth.updateCodeVerifier(this.mcpName, codeVerifier)) + } + + async codeVerifier(): Promise { + const entry = await Effect.runPromise(this.auth.get(this.mcpName)) + if (!entry?.codeVerifier) { + throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`) + } + return entry.codeVerifier + } + + async saveState(state: string): Promise { + await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, state)) + } + + async state(): Promise { + const entry = await Effect.runPromise(this.auth.get(this.mcpName)) + if (entry?.oauthState) { + return entry.oauthState + } + + // Generate a new state if none exists — the SDK calls state() as a + // generator, not just a reader, so we need to produce a value even when + // startAuth() hasn't pre-saved one (e.g. during automatic auth on first + // connect). + const newState = Array.from(crypto.getRandomValues(new Uint8Array(32))) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, newState)) + return newState + } + + async invalidateCredentials(type: "all" | "client" | "tokens"): Promise { + const entry = await Effect.runPromise(this.auth.get(this.mcpName)) + if (!entry) return + switch (type) { + case "all": + await Effect.runPromise(this.auth.remove(this.mcpName)) + break + case "client": + delete entry.clientInfo + await Effect.runPromise(this.auth.set(this.mcpName, entry)) + break + case "tokens": + delete entry.tokens + await Effect.runPromise(this.auth.set(this.mcpName, entry)) + break + } + } +} + +export class McpOAuthPendingProvider extends McpOAuthProvider { + private pendingClientInfo?: OAuthClientInformationFull + private pendingTokens?: OAuthTokens + + override async clientInformation(): Promise { + if (!this.config.clientId) return this.pendingClientInfo + return { + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + } + } + + override async saveClientInformation(info: OAuthClientInformationFull): Promise { + this.pendingClientInfo = info + } + + override async tokens(): Promise { + return this.pendingTokens + } + + override async saveTokens(tokens: OAuthTokens): Promise { + this.pendingTokens = tokens + } + + override async invalidateCredentials(type: "all" | "client" | "tokens"): Promise { + if (type === "all" || type === "client") this.pendingClientInfo = undefined + if (type === "all" || type === "tokens") this.pendingTokens = undefined + } + + async commit(): Promise { + if (!this.pendingTokens) return + await Effect.runPromise( + this.auth.set( + this.mcpName, + { + tokens: { + accessToken: this.pendingTokens.access_token, + refreshToken: this.pendingTokens.refresh_token, + expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined, + scope: this.pendingTokens.scope, + }, + clientInfo: + this.pendingClientInfo && !this.config.clientId + ? { + clientId: this.pendingClientInfo.client_id, + clientSecret: this.pendingClientInfo.client_secret, + clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at, + clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at, + } + : undefined, + }, + this.serverUrl, + ), + ) + } +} + +export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } + +/** + * Parse a redirect URI to extract port and path for the callback server. + * Returns defaults if the URI can't be parsed. + */ +export function parseRedirectUri(redirectUri?: string): { port: number; path: string } { + if (!redirectUri) { + return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } + } + + try { + const url = new URL(redirectUri) + const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80 + const path = url.pathname || OAUTH_CALLBACK_PATH + return { port, path } + } catch { + return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } + } +} diff --git a/packages/opencode/src/node.ts b/packages/opencode/src/node.ts new file mode 100644 index 0000000000000000000000000000000000000000..04cd95a58c4286aa807150dd39f8b82ff8bbc38d --- /dev/null +++ b/packages/opencode/src/node.ts @@ -0,0 +1,4 @@ +export { Config } from "@/config/config" +export { Server } from "./server/server" +export { bootstrap } from "./cli/bootstrap" +export { Database } from "@opencode-ai/core/database/database" diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..66797d4f89ba4de7d0196b2fa3d612c056dcbcfa --- /dev/null +++ b/packages/opencode/src/patch/index.ts @@ -0,0 +1,686 @@ +import { Effect, Schema } from "effect" +import * as path from "path" +import { FSUtil } from "@opencode-ai/core/fs-util" +import * as Bom from "../util/bom" + +export const PatchSchema = Schema.Struct({ + patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }), +}) + +export type PatchParams = Schema.Schema.Type + +// Core types matching the Rust implementation +export interface ApplyPatchArgs { + patch: string + hunks: Hunk[] + workdir?: string +} + +export type Hunk = + | { type: "add"; path: string; contents: string } + | { type: "delete"; path: string } + | { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] } + +export interface UpdateFileChunk { + old_lines: string[] + new_lines: string[] + change_context?: string + is_end_of_file?: boolean +} + +export interface ApplyPatchAction { + changes: Map + patch: string + cwd: string +} + +export type ApplyPatchFileChange = + | { type: "add"; content: string } + | { type: "delete"; content: string } + | { type: "update"; unified_diff: string; move_path?: string; new_content: string } + +export interface AffectedPaths { + added: string[] + modified: string[] + deleted: string[] +} + +export enum ApplyPatchError { + ParseError = "ParseError", + IoError = "IoError", + ComputeReplacements = "ComputeReplacements", + ImplicitInvocation = "ImplicitInvocation", +} + +export enum MaybeApplyPatch { + Body = "Body", + ShellParseError = "ShellParseError", + PatchParseError = "PatchParseError", + NotApplyPatch = "NotApplyPatch", +} + +export enum MaybeApplyPatchVerified { + Body = "Body", + ShellParseError = "ShellParseError", + CorrectnessError = "CorrectnessError", + NotApplyPatch = "NotApplyPatch", +} + +// Parser implementation +function parsePatchHeader( + lines: string[], + startIdx: number, +): { filePath: string; movePath?: string; nextIdx: number } | null { + const line = lines[startIdx] + + if (line.startsWith("*** Add File:")) { + const filePath = line.slice("*** Add File:".length).trim() + return filePath ? { filePath, nextIdx: startIdx + 1 } : null + } + + if (line.startsWith("*** Delete File:")) { + const filePath = line.slice("*** Delete File:".length).trim() + return filePath ? { filePath, nextIdx: startIdx + 1 } : null + } + + if (line.startsWith("*** Update File:")) { + const filePath = line.slice("*** Update File:".length).trim() + let movePath: string | undefined + let nextIdx = startIdx + 1 + + // Check for move directive + if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) { + movePath = lines[nextIdx].slice("*** Move to:".length).trim() + nextIdx++ + } + + return filePath ? { filePath, movePath, nextIdx } : null + } + + return null +} + +function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } { + const chunks: UpdateFileChunk[] = [] + let i = startIdx + + while (i < lines.length && !lines[i].startsWith("***")) { + if (lines[i].startsWith("@@")) { + // Parse context line + const contextLine = lines[i].substring(2).trim() + i++ + + const oldLines: string[] = [] + const newLines: string[] = [] + let isEndOfFile = false + + // Parse change lines + while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) { + const changeLine = lines[i] + + if (changeLine === "*** End of File") { + isEndOfFile = true + i++ + break + } + + if (changeLine.startsWith(" ")) { + // Keep line - appears in both old and new + const content = changeLine.substring(1) + oldLines.push(content) + newLines.push(content) + } else if (changeLine.startsWith("-")) { + // Remove line - only in old + oldLines.push(changeLine.substring(1)) + } else if (changeLine.startsWith("+")) { + // Add line - only in new + newLines.push(changeLine.substring(1)) + } + + i++ + } + + chunks.push({ + old_lines: oldLines, + new_lines: newLines, + change_context: contextLine || undefined, + is_end_of_file: isEndOfFile || undefined, + }) + } else { + i++ + } + } + + return { chunks, nextIdx: i } +} + +function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } { + let content = "" + let i = startIdx + + while (i < lines.length && !lines[i].startsWith("***")) { + if (lines[i].startsWith("+")) { + content += lines[i].substring(1) + "\n" + } + i++ + } + + // Remove trailing newline + if (content.endsWith("\n")) { + content = content.slice(0, -1) + } + + return { content, nextIdx: i } +} + +function stripHeredoc(input: string): string { + // Match heredoc patterns like: cat <<'EOF'\n...\nEOF or < line.trim() === beginMarker) + const endIdx = lines.findIndex((line) => line.trim() === endMarker) + + if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) { + throw new Error("Invalid patch format: missing Begin/End markers") + } + + // Parse content between markers + i = beginIdx + 1 + + while (i < endIdx) { + const header = parsePatchHeader(lines, i) + if (!header) { + i++ + continue + } + + if (lines[i].startsWith("*** Add File:")) { + const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx) + hunks.push({ + type: "add", + path: header.filePath, + contents: content, + }) + i = nextIdx + } else if (lines[i].startsWith("*** Delete File:")) { + hunks.push({ + type: "delete", + path: header.filePath, + }) + i = header.nextIdx + } else if (lines[i].startsWith("*** Update File:")) { + const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx) + hunks.push({ + type: "update", + path: header.filePath, + move_path: header.movePath, + chunks, + }) + i = nextIdx + } else { + i++ + } + } + + return { hunks } +} + +// Apply patch functionality +export function maybeParseApplyPatch( + argv: string[], +): + | { type: MaybeApplyPatch.Body; args: ApplyPatchArgs } + | { type: MaybeApplyPatch.PatchParseError; error: Error } + | { type: MaybeApplyPatch.NotApplyPatch } { + const APPLY_PATCH_COMMANDS = ["apply_patch", "applypatch"] + + // Direct invocation: apply_patch + if (argv.length === 2 && APPLY_PATCH_COMMANDS.includes(argv[0])) { + try { + const { hunks } = parsePatch(argv[1]) + return { + type: MaybeApplyPatch.Body, + args: { + patch: argv[1], + hunks, + }, + } + } catch (error) { + return { + type: MaybeApplyPatch.PatchParseError, + error: error as Error, + } + } + } + + // Bash heredoc form: bash -lc 'apply_patch <<"EOF" ...' + if (argv.length === 3 && argv[0] === "bash" && argv[1] === "-lc") { + // Simple extraction - in real implementation would need proper bash parsing + const script = argv[2] + const heredocMatch = script.match(/apply_patch\s*<<['"](\w+)['"]\s*\n([\s\S]*?)\n\1/) + + if (heredocMatch) { + const patchContent = heredocMatch[2] + try { + const { hunks } = parsePatch(patchContent) + return { + type: MaybeApplyPatch.Body, + args: { + patch: patchContent, + hunks, + }, + } + } catch (error) { + return { + type: MaybeApplyPatch.PatchParseError, + error: error as Error, + } + } + } + } + + return { type: MaybeApplyPatch.NotApplyPatch } +} + +// File content manipulation +interface ApplyPatchFileUpdate { + unified_diff: string + content: string + bom: boolean +} + +export function deriveNewContentsFromChunks( + filePath: string, + chunks: UpdateFileChunk[], + originalText: string, +): ApplyPatchFileUpdate { + const originalContent = Bom.split(originalText) + + let originalLines = originalContent.text.split("\n") + + // Drop trailing empty element for consistent line counting + if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { + originalLines.pop() + } + + const replacements = computeReplacements(originalLines, filePath, chunks) + let newLines = applyReplacements(originalLines, replacements) + + // Ensure trailing newline + if (newLines.length === 0 || newLines[newLines.length - 1] !== "") { + newLines.push("") + } + + const next = Bom.split(newLines.join("\n")) + const newContent = next.text + + // Generate unified diff + const unifiedDiff = generateUnifiedDiff(originalContent.text, newContent) + + return { + unified_diff: unifiedDiff, + content: newContent, + bom: originalContent.bom || next.bom, + } +} + +function computeReplacements( + originalLines: string[], + filePath: string, + chunks: UpdateFileChunk[], +): Array<[number, number, string[]]> { + const replacements: Array<[number, number, string[]]> = [] + let lineIndex = 0 + + for (const chunk of chunks) { + // Handle context-based seeking + if (chunk.change_context) { + const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex) + if (contextIdx === -1) { + throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`) + } + lineIndex = contextIdx + 1 + } + + // Handle pure addition (no old lines) + if (chunk.old_lines.length === 0) { + const insertionIdx = + originalLines.length > 0 && originalLines[originalLines.length - 1] === "" + ? originalLines.length - 1 + : originalLines.length + replacements.push([insertionIdx, 0, chunk.new_lines]) + continue + } + + // Try to match old lines in the file + let pattern = chunk.old_lines + let newSlice = chunk.new_lines + let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) + + // Retry without trailing empty line if not found + if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") { + pattern = pattern.slice(0, -1) + if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") { + newSlice = newSlice.slice(0, -1) + } + found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) + } + + if (found !== -1) { + replacements.push([found, pattern.length, newSlice]) + lineIndex = found + pattern.length + } else { + throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}`) + } + } + + // Sort replacements by index to apply in order + replacements.sort((a, b) => a[0] - b[0]) + + return replacements +} + +function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] { + // Apply replacements in reverse order to avoid index shifting + const result = [...lines] + + for (let i = replacements.length - 1; i >= 0; i--) { + const [startIdx, oldLen, newSegment] = replacements[i] + + // Remove old lines + result.splice(startIdx, oldLen) + + // Insert new lines + for (let j = 0; j < newSegment.length; j++) { + result.splice(startIdx + j, 0, newSegment[j]) + } + } + + return result +} + +// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode) +function normalizeUnicode(str: string): string { + return str + .replace(/[‘’‚‛]/g, "'") // single quotes + .replace(/[“”„‟]/g, '"') // double quotes + .replace(/[‐‑‒–—―]/g, "-") // dashes + .replace(/…/g, "...") // ellipsis + .replace(/ /g, " ") // non-breaking space +} + +type Comparator = (a: string, b: string) => boolean + +function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number { + // If EOF anchor, try matching from end of file first + if (eof) { + const fromEnd = lines.length - pattern.length + if (fromEnd >= startIndex) { + let matches = true + for (let j = 0; j < pattern.length; j++) { + if (!compare(lines[fromEnd + j], pattern[j])) { + matches = false + break + } + } + if (matches) return fromEnd + } + } + + // Forward search from startIndex + for (let i = startIndex; i <= lines.length - pattern.length; i++) { + let matches = true + for (let j = 0; j < pattern.length; j++) { + if (!compare(lines[i + j], pattern[j])) { + matches = false + break + } + } + if (matches) return i + } + + return -1 +} + +function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number { + if (pattern.length === 0) return -1 + + // Pass 1: exact match + const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof) + if (exact !== -1) return exact + + // Pass 2: rstrip (trim trailing whitespace) + const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof) + if (rstrip !== -1) return rstrip + + // Pass 3: trim (both ends) + const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof) + if (trim !== -1) return trim + + // Pass 4: normalized (Unicode punctuation to ASCII) + const normalized = tryMatch( + lines, + pattern, + startIndex, + (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), + eof, + ) + return normalized +} + +function generateUnifiedDiff(oldContent: string, newContent: string): string { + const oldLines = oldContent.split("\n") + const newLines = newContent.split("\n") + + // Simple diff generation - in a real implementation you'd use a proper diff algorithm + let diff = "@@ -1 +1 @@\n" + + // Find changes (simplified approach) + const maxLen = Math.max(oldLines.length, newLines.length) + let hasChanges = false + + for (let i = 0; i < maxLen; i++) { + const oldLine = oldLines[i] || "" + const newLine = newLines[i] || "" + + if (oldLine !== newLine) { + if (oldLine) diff += `-${oldLine}\n` + if (newLine) diff += `+${newLine}\n` + hasChanges = true + } else if (oldLine) { + diff += ` ${oldLine}\n` + } + } + + return hasChanges ? diff : "" +} + +// Apply hunks to filesystem +export const applyHunksToFiles = Effect.fn("Patch.applyHunksToFiles")(function* (hunks: Hunk[]) { + if (hunks.length === 0) { + return yield* Effect.fail(new Error("No files were modified.")) + } + + const fs = yield* FSUtil.Service + + const added: string[] = [] + const modified: string[] = [] + const deleted: string[] = [] + + for (const hunk of hunks) { + switch (hunk.type) { + case "add": { + yield* fs.writeWithDirs(hunk.path, hunk.contents) + added.push(hunk.path) + yield* Effect.logInfo(`Added file: ${hunk.path}`) + break + } + + case "delete": { + yield* fs.remove(hunk.path) + deleted.push(hunk.path) + yield* Effect.logInfo(`Deleted file: ${hunk.path}`) + break + } + + case "update": { + const originalText = yield* fs.readFileString(hunk.path) + const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks, originalText) + + if (hunk.move_path) { + yield* fs.writeWithDirs(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom)) + yield* fs.remove(hunk.path) + modified.push(hunk.move_path) + yield* Effect.logInfo(`Moved file: ${hunk.path} -> ${hunk.move_path}`) + } else { + yield* fs.writeWithDirs(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom)) + modified.push(hunk.path) + yield* Effect.logInfo(`Updated file: ${hunk.path}`) + } + break + } + } + } + + return { added, modified, deleted } satisfies AffectedPaths +}) + +// Main patch application function +export const applyPatch = Effect.fn("Patch.applyPatch")(function* (patchText: string) { + const { hunks } = parsePatch(patchText) + return yield* applyHunksToFiles(hunks) +}) + +type MaybeApplyPatchVerifiedResult = + | { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction } + | { type: MaybeApplyPatchVerified.CorrectnessError; error: Error } + | { type: MaybeApplyPatchVerified.NotApplyPatch } + +// Effectful verified-parse: needs FSUtil.Service to read existing files +export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatchVerified")(function* ( + argv: string[], + cwd: string, +) { + // Detect implicit patch invocation (raw patch without apply_patch command) + if (argv.length === 1) { + try { + parsePatch(argv[0]) + return { + type: MaybeApplyPatchVerified.CorrectnessError, + error: new Error(ApplyPatchError.ImplicitInvocation), + } satisfies MaybeApplyPatchVerifiedResult + } catch { + // Not a patch, continue + } + } + + const result = maybeParseApplyPatch(argv) + + switch (result.type) { + case MaybeApplyPatch.Body: { + const fs = yield* FSUtil.Service + const args = result.args + const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd + const changes = new Map() + + for (const hunk of args.hunks) { + const resolvedPath = path.resolve( + effectiveCwd, + hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path, + ) + + switch (hunk.type) { + case "add": + changes.set(resolvedPath, { + type: "add", + content: hunk.contents, + }) + break + + case "delete": { + const deletePath = path.resolve(effectiveCwd, hunk.path) + const content = yield* fs.readFileString(deletePath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (content === undefined) { + return { + type: MaybeApplyPatchVerified.CorrectnessError, + error: new Error(`Failed to read file for deletion: ${deletePath}`), + } satisfies MaybeApplyPatchVerifiedResult + } + changes.set(resolvedPath, { + type: "delete", + content, + }) + break + } + + case "update": { + const updatePath = path.resolve(effectiveCwd, hunk.path) + const originalText = yield* fs + .readFileString(updatePath) + .pipe( + Effect.catch((cause) => + Effect.succeed(new Error(`Failed to read file ${updatePath}: ${cause}`, { cause })), + ), + ) + if (originalText instanceof Error) { + return { + type: MaybeApplyPatchVerified.CorrectnessError, + error: originalText, + } satisfies MaybeApplyPatchVerifiedResult + } + try { + const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks, originalText) + changes.set(resolvedPath, { + type: "update", + unified_diff: fileUpdate.unified_diff, + move_path: hunk.move_path ? path.resolve(effectiveCwd, hunk.move_path) : undefined, + new_content: fileUpdate.content, + }) + } catch (error) { + return { + type: MaybeApplyPatchVerified.CorrectnessError, + error: error as Error, + } satisfies MaybeApplyPatchVerifiedResult + } + break + } + } + } + + return { + type: MaybeApplyPatchVerified.Body, + action: { + changes, + patch: args.patch, + cwd: effectiveCwd, + }, + } satisfies MaybeApplyPatchVerifiedResult + } + + case MaybeApplyPatch.PatchParseError: + return { + type: MaybeApplyPatchVerified.CorrectnessError, + error: result.error, + } satisfies MaybeApplyPatchVerifiedResult + + case MaybeApplyPatch.NotApplyPatch: + return { type: MaybeApplyPatchVerified.NotApplyPatch } satisfies MaybeApplyPatchVerifiedResult + } +}) + +export * as Patch from "." diff --git a/packages/opencode/src/permission/arity.ts b/packages/opencode/src/permission/arity.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd4b0a7d58ec3bc9eefe26968883a7c2e8c2990b --- /dev/null +++ b/packages/opencode/src/permission/arity.ts @@ -0,0 +1,163 @@ +export function prefix(tokens: string[]) { + for (let len = tokens.length; len > 0; len--) { + const prefix = tokens.slice(0, len).join(" ") + const arity = ARITY[prefix] + if (arity !== undefined) return tokens.slice(0, arity) + } + if (tokens.length === 0) return [] + return tokens.slice(0, 1) +} + +/* Generated with following prompt: +You are generating a dictionary of command-prefix arities for bash-style commands. +This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string → number**, representing how many **tokens** define the command. +2. **Flags NEVER count as tokens**. Only subcommands count. +3. **Longest matching prefix wins**. +4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**. * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity. +5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical +6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` → `touch` (arity 1, explicitly listed) +* `git checkout main` → `git checkout` (because `git` has arity 2) +* `npm install` → `npm install` (because `npm` has arity 2) +* `npm run dev` → `npm run dev` (because `npm run` has arity 3) +* `python script.py` → `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.** +*/ +const ARITY: Record = { + cat: 1, // cat file.txt + cd: 1, // cd /path/to/dir + chmod: 1, // chmod 755 script.sh + chown: 1, // chown user:group file.txt + cp: 1, // cp source.txt dest.txt + echo: 1, // echo "hello world" + env: 1, // env + export: 1, // export PATH=/usr/bin + grep: 1, // grep pattern file.txt + kill: 1, // kill 1234 + killall: 1, // killall process + ln: 1, // ln -s source target + ls: 1, // ls -la + mkdir: 1, // mkdir new-dir + mv: 1, // mv old.txt new.txt + ps: 1, // ps aux + pwd: 1, // pwd + rm: 1, // rm file.txt + rmdir: 1, // rmdir empty-dir + sleep: 1, // sleep 5 + source: 1, // source ~/.bashrc + tail: 1, // tail -f log.txt + touch: 1, // touch file.txt + unset: 1, // unset VAR + which: 1, // which node + aws: 3, // aws s3 ls + az: 3, // az storage blob list + bazel: 2, // bazel build + brew: 2, // brew install node + bun: 2, // bun install + "bun run": 3, // bun run dev + "bun x": 3, // bun x vite + cargo: 2, // cargo build + "cargo add": 3, // cargo add tokio + "cargo run": 3, // cargo run main + cdk: 2, // cdk deploy + cf: 2, // cf push app + cmake: 2, // cmake build + composer: 2, // composer require laravel + consul: 2, // consul members + "consul kv": 3, // consul kv get config/app + crictl: 2, // crictl ps + deno: 2, // deno run server.ts + "deno task": 3, // deno task dev + doctl: 3, // doctl kubernetes cluster list + docker: 2, // docker run nginx + "docker builder": 3, // docker builder prune + "docker compose": 3, // docker compose up + "docker container": 3, // docker container ls + "docker image": 3, // docker image prune + "docker network": 3, // docker network inspect + "docker volume": 3, // docker volume ls + eksctl: 2, // eksctl get clusters + "eksctl create": 3, // eksctl create cluster + firebase: 2, // firebase deploy + flyctl: 2, // flyctl deploy + gcloud: 3, // gcloud compute instances list + gh: 3, // gh pr list + git: 2, // git checkout main + "git config": 3, // git config user.name + "git remote": 3, // git remote add origin + "git stash": 3, // git stash pop + go: 2, // go build + gradle: 2, // gradle build + helm: 2, // helm install mychart + heroku: 2, // heroku logs + hugo: 2, // hugo new site blog + ip: 2, // ip link show + "ip addr": 3, // ip addr show + "ip link": 3, // ip link set eth0 up + "ip netns": 3, // ip netns exec foo bash + "ip route": 3, // ip route add default via 1.1.1.1 + kind: 2, // kind delete cluster + "kind create": 3, // kind create cluster + kubectl: 2, // kubectl get pods + "kubectl kustomize": 3, // kubectl kustomize overlays/dev + "kubectl rollout": 3, // kubectl rollout restart deploy/api + kustomize: 2, // kustomize build . + make: 2, // make build + mc: 2, // mc ls myminio + "mc admin": 3, // mc admin info myminio + minikube: 2, // minikube start + mongosh: 2, // mongosh test + mysql: 2, // mysql -u root + mvn: 2, // mvn compile + ng: 2, // ng generate component home + npm: 2, // npm install + "npm exec": 3, // npm exec vite + "npm init": 3, // npm init vue + "npm run": 3, // npm run dev + "npm view": 3, // npm view react version + nvm: 2, // nvm use 18 + nx: 2, // nx build + openssl: 2, // openssl genrsa 2048 + "openssl req": 3, // openssl req -new -key key.pem + "openssl x509": 3, // openssl x509 -in cert.pem + pip: 2, // pip install numpy + pipenv: 2, // pipenv install flask + pnpm: 2, // pnpm install + "pnpm dlx": 3, // pnpm dlx create-next-app + "pnpm exec": 3, // pnpm exec vite + "pnpm run": 3, // pnpm run dev + poetry: 2, // poetry add requests + podman: 2, // podman run alpine + "podman container": 3, // podman container ls + "podman image": 3, // podman image prune + psql: 2, // psql -d mydb + pulumi: 2, // pulumi up + "pulumi stack": 3, // pulumi stack output + pyenv: 2, // pyenv install 3.11 + python: 2, // python -m venv env + rake: 2, // rake db:migrate + rbenv: 2, // rbenv install 3.2.0 + "redis-cli": 2, // redis-cli ping + rustup: 2, // rustup update + serverless: 2, // serverless invoke + sfdx: 3, // sfdx force:org:list + skaffold: 2, // skaffold dev + sls: 2, // sls deploy + sst: 2, // sst deploy + swift: 2, // swift build + systemctl: 2, // systemctl restart nginx + terraform: 2, // terraform apply + "terraform workspace": 3, // terraform workspace select prod + tmux: 2, // tmux new -s dev + turbo: 2, // turbo run build + ufw: 2, // ufw allow 22 + vault: 2, // vault login + "vault auth": 3, // vault auth list + "vault kv": 3, // vault kv get secret/api + vercel: 2, // vercel deploy + volta: 2, // volta install node + wp: 2, // wp plugin install + yarn: 2, // yarn add react + "yarn dlx": 3, // yarn dlx create-react-app + "yarn run": 3, // yarn run dev +} + +export * as BashArity from "./arity" diff --git a/packages/opencode/src/permission/evaluate.ts b/packages/opencode/src/permission/evaluate.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7fc5e170902694b9b43e79856996c5e3584681b --- /dev/null +++ b/packages/opencode/src/permission/evaluate.ts @@ -0,0 +1 @@ +export { evaluate } from "." diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e27ff2424dbb000ea9ed7f73471769716ba40a1 --- /dev/null +++ b/packages/opencode/src/permission/index.ts @@ -0,0 +1,223 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" +import { InstanceState } from "@/effect/instance-state" +import { Wildcard } from "@opencode-ai/core/util/wildcard" +import { Deferred, Effect, Layer, Context } from "effect" +import os from "os" +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { EventV2Bridge } from "@/event-v2-bridge" + +export const Event = PermissionV1.Event + +export interface Interface { + readonly ask: (input: PermissionV1.AskInput) => Effect.Effect + readonly reply: (input: PermissionV1.ReplyInput) => Effect.Effect + readonly list: () => Effect.Effect> +} + +interface PendingEntry { + info: PermissionV1.Request + deferred: Deferred.Deferred +} + +interface State { + pending: Map + approved: PermissionV1.Rule[] +} + +export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule { + return ( + rulesets + .flat() + .findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? { + action: "ask", + permission, + pattern: "*", + } + ) +} + +export class Service extends Context.Service()("@opencode/Permission") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2Bridge.Service + const state = yield* InstanceState.make( + Effect.fn("Permission.state")(function* (ctx) { + void ctx + const state = { + pending: new Map(), + approved: [], + } + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const item of state.pending.values()) { + yield* Deferred.fail(item.deferred, new PermissionV1.RejectedError()) + } + state.pending.clear() + }), + ) + + return state + }), + ) + + const ask = Effect.fn("Permission.ask")(function* (input: PermissionV1.AskInput) { + const { approved, pending } = yield* InstanceState.get(state) + const { ruleset, ...request } = input + let needsAsk = false + + for (const pattern of request.patterns) { + const rule = evaluate(request.permission, pattern, ruleset, approved) + yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule }) + if (rule.action === "deny") { + return yield* new PermissionV1.DeniedError({ + ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), + }) + } + if (rule.action === "allow") continue + needsAsk = true + } + + if (!needsAsk) return + + const id = request.id ?? PermissionV1.ID.ascending() + const info: PermissionV1.Request = { + id, + sessionID: request.sessionID, + permission: request.permission, + patterns: request.patterns, + metadata: request.metadata, + always: request.always, + tool: request.tool, + } + yield* Effect.logInfo("asking", { id, permission: info.permission, patterns: info.patterns }) + + const deferred = yield* Deferred.make() + pending.set(id, { info, deferred }) + yield* events.publish(Event.Asked, info) + return yield* Effect.ensuring( + Deferred.await(deferred), + Effect.sync(() => { + pending.delete(id) + }), + ) + }) + + const reply = Effect.fn("Permission.reply")(function* (input: PermissionV1.ReplyInput) { + const { approved, pending } = yield* InstanceState.get(state) + const existing = pending.get(input.requestID) + if (!existing) return yield* new PermissionV1.NotFoundError({ requestID: input.requestID }) + + pending.delete(input.requestID) + yield* events.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + reply: input.reply, + }) + + if (input.reply === "reject") { + yield* Deferred.fail( + existing.deferred, + input.message + ? new PermissionV1.CorrectedError({ feedback: input.message }) + : new PermissionV1.RejectedError(), + ) + + for (const [id, item] of pending.entries()) { + if (item.info.sessionID !== existing.info.sessionID) continue + pending.delete(id) + yield* events.publish(Event.Replied, { + sessionID: item.info.sessionID, + requestID: item.info.id, + reply: "reject", + }) + yield* Deferred.fail(item.deferred, new PermissionV1.RejectedError()) + } + return + } + + yield* Deferred.succeed(existing.deferred, undefined) + if (input.reply === "once") return + + for (const pattern of existing.info.always) { + approved.push({ + permission: existing.info.permission, + pattern, + action: "allow", + }) + } + + for (const [id, item] of pending.entries()) { + if (item.info.sessionID !== existing.info.sessionID) continue + const ok = item.info.patterns.every( + (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow", + ) + if (!ok) continue + pending.delete(id) + yield* events.publish(Event.Replied, { + sessionID: item.info.sessionID, + requestID: item.info.id, + reply: "always", + }) + yield* Deferred.succeed(item.deferred, undefined) + } + }) + + const list = Effect.fn("Permission.list")(function* () { + const pending = (yield* InstanceState.get(state)).pending + return Array.from(pending.values(), (item) => item.info) + }) + + return Service.of({ ask, reply, list }) + }), +) + +function expand(pattern: string): string { + if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) + if (pattern === "~") return os.homedir() + if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5) + if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5) + return pattern +} + +export function fromConfig(permission: ConfigPermissionV1.Info) { + const ruleset: PermissionV1.Rule[] = [] + for (const [key, value] of Object.entries(permission)) { + if (typeof value === "string") { + ruleset.push({ permission: key, action: value, pattern: "*" }) + continue + } + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), + ) + } + return ruleset +} + +export function merge(...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule[] { + return rulesets.flat() +} + +export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set { + const edits = ["edit", "write", "apply_patch"] + const reads = ["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"] + return new Set( + tools.filter((tool) => { + const permission = edits.includes(tool) ? "edit" : reads.includes(tool) ? "read" : tool + const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) + return rule?.pattern === "*" && rule.action === "deny" + }), + ) +} + +export function visibleTools(tools: Record, ruleset: PermissionV1.Ruleset): Record { + const hidden = disabled(Object.keys(tools), ruleset) + return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name))) +} + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) + +export * as Permission from "." diff --git a/packages/opencode/src/plugin/azure.ts b/packages/opencode/src/plugin/azure.ts new file mode 100644 index 0000000000000000000000000000000000000000..6916aaaf3a65d063e861e5dddf46bf9b8d00b812 --- /dev/null +++ b/packages/opencode/src/plugin/azure.ts @@ -0,0 +1,122 @@ +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { which } from "@opencode-ai/core/util/which" +import type { Hooks } from "@opencode-ai/plugin" +import { Schema } from "effect" +import { OAUTH_DUMMY_KEY } from "../auth" +import { Process } from "../util/process" + +const AZURE_COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default" +const AZURE_FOUNDRY_SCOPE = "https://ai.azure.com/.default" +const AZURE_TOKEN_REFRESH_BUFFER = 60_000 + +const AzureCliToken = Schema.Struct({ + accessToken: Schema.NonEmptyString, + expires_on: Schema.optional(Schema.Number), + expiresOn: Schema.optional(Schema.NonEmptyString), +}) +const decodeAzureCliToken = Schema.decodeUnknownPromise(AzureCliToken) +type AzureCommand = (args: string[]) => Promise + +export async function AzureAuthPlugin(): Promise { + const available = Boolean(which("az")) + return createAzureAuthHooks(runAzure, fetch, available) +} + +export function createAzureAuthHooks( + run: AzureCommand, + request: (input: RequestInfo | URL, init?: RequestInit) => Promise, + available: boolean, +): Hooks { + const tokens = new Map() + async function token(scope: string) { + const cached = tokens.get(scope) + if (cached && cached.expires - Date.now() > AZURE_TOKEN_REFRESH_BUFFER) return cached.token + + const result = await decodeAzureCliToken( + await run(["account", "get-access-token", "--scope", scope, "--output", "json"]), + ) + const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "") + if (!Number.isFinite(expires)) throw new Error("Azure CLI returned an invalid token expiration") + const refreshed = { token: result.accessToken, expires } + tokens.set(scope, refreshed) + return refreshed.token + } + + const prompts = [] + if (!process.env.AZURE_RESOURCE_NAME) { + prompts.push({ + type: "text" as const, + key: "resourceName", + message: "Enter Azure Resource Name", + placeholder: "e.g. my-models", + }) + } + const hooks: Hooks = { + auth: { + provider: "azure", + async loader(getAuth) { + if ((await getAuth()).type !== "oauth") return {} + + return { + apiKey: OAUTH_DUMMY_KEY, + async fetch(input: RequestInfo | URL, init?: RequestInit) { + const headers = new Headers(input instanceof Request ? input.headers : undefined) + new Headers(init?.headers).forEach((value, key) => headers.set(key, value)) + headers.delete("api-key") + headers.delete("x-api-key") + headers.set("authorization", `Bearer ${await token(scopeForRequest(input))}`) + headers.set("User-Agent", `opencode/${InstallationVersion}`) + return request(input, { ...init, headers }) + }, + } + }, + methods: [ + { + type: "api", + label: "API key", + prompts, + }, + { + type: "oauth", + label: "Microsoft Entra ID (Azure CLI)", + prompts, + async authorize(inputs) { + return { + url: "", + instructions: "Sign in with `az login` before continuing.", + method: "auto", + callback: async () => { + const resourceName = inputs?.resourceName ?? process.env.AZURE_RESOURCE_NAME + if (!resourceName) throw new Error("Azure Resource Name is required") + + await token(AZURE_COGNITIVE_SERVICES_SCOPE) + return { + type: "success", + access: OAUTH_DUMMY_KEY, + refresh: OAUTH_DUMMY_KEY, + expires: Date.now() + 365 * 24 * 60 * 60 * 1000, + accountId: resourceName, + } + }, + } + }, + }, + ], + }, + } + if (!available && hooks.auth) hooks.auth.methods = hooks.auth.methods.filter((method) => method.type !== "oauth") + return hooks +} + +async function runAzure(args: string[]): Promise { + const result = await Process.run([which("az") ?? "az", ...args]) + return JSON.parse(result.stdout.toString()) +} + +function scopeForRequest(input: RequestInfo | URL) { + const url = new URL(input instanceof Request ? input.url : input) + if (url.hostname.endsWith(".services.ai.azure.com") && !url.pathname.startsWith("/models")) { + return AZURE_FOUNDRY_SCOPE + } + return AZURE_COGNITIVE_SERVICES_SCOPE +} diff --git a/packages/opencode/src/plugin/cerebras.ts b/packages/opencode/src/plugin/cerebras.ts new file mode 100644 index 0000000000000000000000000000000000000000..b21a6add4270c58b1d9c1090110775344d80825f --- /dev/null +++ b/packages/opencode/src/plugin/cerebras.ts @@ -0,0 +1,11 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" + +export async function CerebrasPlugin(_input: PluginInput): Promise { + return { + "chat.params": async (input, output) => { + if (input.model.api.npm !== "@ai-sdk/cerebras") return + if (output.options.max_completion_tokens === undefined) return + output.maxOutputTokens = undefined + }, + } +} diff --git a/packages/opencode/src/plugin/cloudflare.ts b/packages/opencode/src/plugin/cloudflare.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ccf5168d8a445c3c2e0d9f81d486da0195f63b4 --- /dev/null +++ b/packages/opencode/src/plugin/cloudflare.ts @@ -0,0 +1,65 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" + +export async function CloudflareWorkersAuthPlugin(_input: PluginInput): Promise { + const prompts = !process.env.CLOUDFLARE_ACCOUNT_ID + ? [ + { + type: "text" as const, + key: "accountId", + message: "Enter your Cloudflare Account ID", + placeholder: "e.g. 1234567890abcdef1234567890abcdef", + }, + ] + : [] + + return { + auth: { + provider: "cloudflare-workers-ai", + methods: [ + { + type: "api", + label: "API key", + prompts, + }, + ], + }, + } +} + +export async function CloudflareAIGatewayAuthPlugin(_input: PluginInput): Promise { + const prompts = [ + ...(!process.env.CLOUDFLARE_ACCOUNT_ID + ? [ + { + type: "text" as const, + key: "accountId", + message: "Enter your Cloudflare Account ID", + placeholder: "e.g. 1234567890abcdef1234567890abcdef", + }, + ] + : []), + ...(!process.env.CLOUDFLARE_GATEWAY_ID + ? [ + { + type: "text" as const, + key: "gatewayId", + message: "Enter your Cloudflare AI Gateway ID", + placeholder: "e.g. my-gateway", + }, + ] + : []), + ] + + return { + auth: { + provider: "cloudflare-ai-gateway", + methods: [ + { + type: "api", + label: "Gateway API token", + prompts, + }, + ], + }, + } +} diff --git a/packages/opencode/src/plugin/digitalocean.ts b/packages/opencode/src/plugin/digitalocean.ts new file mode 100644 index 0000000000000000000000000000000000000000..af241781e61b78574a9e1c25389518f690a4daab --- /dev/null +++ b/packages/opencode/src/plugin/digitalocean.ts @@ -0,0 +1,325 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import type { Model } from "@opencode-ai/sdk/v2" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" +import { createServer } from "http" +import open from "open" + +const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82" +const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize" +const DO_API_BASE = "https://api.digitalocean.com" +const DO_GENAI_API = `${DO_API_BASE}/v2/gen-ai` +const DO_INFERENCE_BASE = "https://inference.do-ai.run/v1" +const OAUTH_PORT = 1456 +const OAUTH_REDIRECT_PATH = "/auth/callback" +const OAUTH_TOKEN_PATH = "/auth/token" +const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000 +const OAUTH_SCOPES = "genai:read inference:query" + +interface ImplicitTokenPayload { + access_token: string + expires_in: number + state: string +} + +interface PendingOAuth { + state: string + resolve: (tokens: ImplicitTokenPayload) => void + reject: (error: Error) => void +} + +interface RouterEntry { + name: string + uuid?: string + description?: string +} + +let oauthServer: ReturnType | undefined +let pendingOAuth: PendingOAuth | undefined + +function generateState(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)) + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +function redirectUri(): string { + return `http://localhost:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}` +} + +function buildAuthorizeUrl(state: string): string { + const params = new URLSearchParams({ + response_type: "token", + client_id: DO_OAUTH_CLIENT_ID, + redirect_uri: redirectUri(), + scope: OAUTH_SCOPES, + state, + }) + return `${DO_AUTHORIZE_URL}?${params.toString()}` +} + +async function startOAuthServer(): Promise { + if (oauthServer) return + oauthServer = createServer((req, res) => { + const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`) + + if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) { + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(OauthCallbackPage.bootstrap({ tokenPath: OAUTH_TOKEN_PATH, provider: "DigitalOcean" })) + return + } + + if (req.method === "POST" && url.pathname === OAUTH_TOKEN_PATH) { + const chunks: Buffer[] = [] + req.on("data", (chunk: Buffer) => chunks.push(chunk)) + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8") + let body: Record = {} + try { + body = raw ? JSON.parse(raw) : {} + } catch { + body = {} + } + if (!pendingOAuth) { + res.writeHead(409, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "no_pending_oauth" })) + return + } + if (body.error) { + const message = body.error_description || body.error || "OAuth error" + pendingOAuth.reject(new Error(String(message))) + pendingOAuth = undefined + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ ok: true })) + return + } + if (!body.access_token) { + pendingOAuth.reject(new Error("Missing access_token in callback")) + pendingOAuth = undefined + res.writeHead(400, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "missing_access_token" })) + return + } + if (body.state !== pendingOAuth.state) { + pendingOAuth.reject(new Error("Invalid state - potential CSRF attack")) + pendingOAuth = undefined + res.writeHead(400, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "invalid_state" })) + return + } + const expires = parseInt(body.expires_in || "0", 10) + pendingOAuth.resolve({ + access_token: body.access_token, + expires_in: Number.isFinite(expires) && expires > 0 ? expires : 60 * 60 * 24 * 30, + state: body.state, + }) + pendingOAuth = undefined + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ ok: true })) + }) + return + } + + res.writeHead(404) + res.end("Not found") + }) + + await new Promise((resolve, reject) => { + oauthServer!.listen(OAUTH_PORT, () => { + resolve() + }) + oauthServer!.on("error", reject) + }) +} + +function stopOAuthServer() { + if (!oauthServer) return + oauthServer.close() + oauthServer = undefined +} + +function waitForOAuthCallback(state: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => { + if (pendingOAuth) { + pendingOAuth = undefined + reject(new Error("OAuth callback timeout - authorization took too long")) + } + }, + 5 * 60 * 1000, + ) + pendingOAuth = { + state, + resolve: (tokens) => { + clearTimeout(timeout) + resolve(tokens) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + }, + } + }) +} + +async function listRouters( + bearer: string, +): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> { + const res = await fetch(`${DO_GENAI_API}/models/routers`, { + headers: { + Authorization: `Bearer ${bearer}`, + Accept: "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + }, + signal: AbortSignal.timeout(10_000), + }).catch(() => undefined) + if (!res) return { ok: false, status: 0 } + if (!res.ok) return { ok: false, status: res.status } + const body = (await res.json().catch(() => undefined)) as { model_routers?: RouterEntry[] } | undefined + return { ok: true, routers: body?.model_routers ?? [] } +} + +function routerModel(router: RouterEntry, providerID: string): Model { + const id = `router:${router.name}` + return { + id, + providerID, + name: router.name, + family: "digitalocean-inference-routers", + api: { id, url: DO_INFERENCE_BASE, npm: "@ai-sdk/openai-compatible" }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128_000, output: 8_192 }, + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } +} + +function parseRoutersJSON(raw: string | undefined): RouterEntry[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.flatMap((r) => + r && typeof r.name === "string" ? [{ name: r.name, uuid: r.uuid, description: r.description }] : [], + ) + } catch { + return [] + } +} + +export async function DigitalOceanAuthPlugin(input: PluginInput): Promise { + return { + provider: { + id: "digitalocean", + async models(provider, ctx) { + const baseModels = provider.models + if (ctx.auth?.type !== "api") return baseModels + + const metadata = ctx.auth.metadata ?? {} + const oauthAccess = metadata["oauth_access"] + const oauthExpires = parseInt(metadata["oauth_expires"] || "0", 10) + const fetchedAt = parseInt(metadata["routers_fetched_at"] || "0", 10) + const cached = parseRoutersJSON(metadata["routers"]) + + let routers = cached + const stale = Date.now() - fetchedAt > ROUTER_REFRESH_INTERVAL_MS + const bearerValid = oauthAccess && oauthExpires > Date.now() + + if (bearerValid && stale) { + const result = await listRouters(oauthAccess) + if (result.ok) { + routers = result.routers + const updated: Record = { + ...metadata, + routers: JSON.stringify(routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description }))), + routers_fetched_at: String(Date.now()), + } + await input.client.auth + .set({ + path: { id: "digitalocean" }, + body: { type: "api", key: ctx.auth.key, metadata: updated }, + }) + .catch(() => {}) + } else if (result.status === 401 || result.status === 403) { + } else if (result.status !== 0) { + } + } + + const merged: Record = { ...baseModels } + for (const router of routers) { + const id = `router:${router.name}` + if (merged[id]) continue + merged[id] = routerModel(router, "digitalocean") + } + return merged + }, + }, + auth: { + provider: "digitalocean", + methods: [ + { + type: "oauth", + label: "Login with DigitalOcean", + async authorize() { + await startOAuthServer() + const state = generateState() + const callbackPromise = waitForOAuthCallback(state) + const url = buildAuthorizeUrl(state) + await open(url).catch(() => undefined) + return { + url, + instructions: + "Sign in to DigitalOcean in your browser. OpenCode will use your DigitalOcean API token directly for inference and load your Inference Routers. Re-run /connect to refresh routers later.", + method: "auto" as const, + async callback() { + try { + const tokens = await callbackPromise + const routerResult = await listRouters(tokens.access_token) + const routers = routerResult.ok ? routerResult.routers : [] + if (!routerResult.ok) { + } + return { + type: "success" as const, + provider: "digitalocean", + key: tokens.access_token, + metadata: { + oauth_access: tokens.access_token, + oauth_expires: String(Date.now() + tokens.expires_in * 1000), + oauth_scopes: OAUTH_SCOPES, + routers: JSON.stringify( + routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })), + ), + routers_fetched_at: String(Date.now()), + }, + } + } catch (err) { + return { type: "failed" as const } + } finally { + stopOAuthServer() + } + }, + } + }, + }, + { + type: "api", + label: "Paste Model Access Key", + }, + ], + }, + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f05329a0833c0bb698572fba279e5bffc3bce49 --- /dev/null +++ b/packages/opencode/src/plugin/index.ts @@ -0,0 +1,318 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import type { + Hooks, + PluginInput, + Plugin as PluginInstance, + PluginModule, + WorkspaceAdapter as PluginWorkspaceAdapter, +} from "@opencode-ai/plugin" +import { Config } from "@/config/config" +import { createOpencodeClient } from "@opencode-ai/sdk" +import { ServerAuth } from "@/server/auth" +import { CodexAuthPlugin } from "./openai/codex" +import { Session } from "@/session/session" +import { NamedError } from "@opencode-ai/core/util/error" +import { CopilotAuthPlugin } from "./github-copilot/copilot" +import { ModalPlugin } from "./modal/modal" +import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" +import { PoeAuthPlugin } from "opencode-poe-auth" +import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" +import { AzureAuthPlugin } from "./azure" +import { DigitalOceanAuthPlugin } from "./digitalocean" +import { XaiAuthPlugin } from "./xai" +import { CerebrasPlugin } from "./cerebras" +import { SnowflakeCortexAuthPlugin } from "./snowflake-cortex" +import { Effect, Layer, Context } from "effect" +import { EffectBridge } from "@/effect/bridge" +import { InstanceState } from "@/effect/instance-state" +import { errorMessage } from "@/util/error" +import { PluginLoader } from "./loader" +import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" +import { registerAdapter } from "@/control-plane/adapters" +import type { WorkspaceAdapter } from "@/control-plane/types" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstallationChannel } from "@opencode-ai/core/installation/version" + +type State = { + hooks: Hooks[] +} + +// Hook names that follow the (input, output) => Promise trigger pattern +type TriggerName = { + [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never +}[keyof Hooks] + +export interface Interface { + readonly trigger: < + Name extends TriggerName, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >( + name: Name, + input: Input, + output: Output, + ) => Effect.Effect + readonly list: () => Effect.Effect + readonly init: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Plugin") {} + +export function experimentalWebSocketsEnabled(input: { enabled: boolean; channel?: string }) { + return input.enabled || ["local", "dev", "beta"].includes(input.channel ?? InstallationChannel) +} + +// Built-in plugins that are directly imported (not installed from npm) +function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] { + return [ + // Temporary rollout: pre-release builds use WebSockets by default; releases require explicit opt-in. + (input) => + CodexAuthPlugin(input, { + experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }), + }), + CopilotAuthPlugin, + ModalPlugin, + GitlabAuthPlugin, + PoeAuthPlugin, + CloudflareWorkersAuthPlugin, + CloudflareAIGatewayAuthPlugin, + AzureAuthPlugin, + DigitalOceanAuthPlugin, + SnowflakeCortexAuthPlugin, + XaiAuthPlugin, + CerebrasPlugin, + ] +} + +function isServerPlugin(value: unknown): value is PluginInstance { + return typeof value === "function" +} + +function getServerPlugin(value: unknown) { + if (isServerPlugin(value)) return value + if (!value || typeof value !== "object" || !("server" in value)) return + if (!isServerPlugin(value.server)) return + return value.server +} + +function getLegacyPlugins(mod: Record) { + const seen = new Set() + const result: PluginInstance[] = [] + + for (const entry of Object.values(mod)) { + if (seen.has(entry)) continue + seen.add(entry) + const plugin = getServerPlugin(entry) + if (!plugin) throw new TypeError("Plugin export is not a function") + result.push(plugin) + } + + return result +} + +async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) { + const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") + if (plugin) { + await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) + hooks.push(await (plugin as PluginModule).server(input, load.options)) + return + } + + for (const server of getLegacyPlugins(load.mod)) { + hooks.push(await server(input, load.options)) + } +} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2Bridge.Service + const config = yield* Config.Service + const flags = yield* RuntimeFlags.Service + + const state = yield* InstanceState.make( + Effect.fn("Plugin.state")(function* (ctx) { + const hooks: Hooks[] = [] + const bridge = yield* EffectBridge.make() + + function publishPluginError(message: string) { + bridge.fork(events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) + } + + const { Server } = yield* Effect.promise(() => import("../server/server")) + + const serverUrl = Server.url + const client = createOpencodeClient({ + baseUrl: serverUrl?.toString() ?? "http://localhost:4096", + directory: ctx.directory, + headers: ServerAuth.headers(), + ...(serverUrl ? {} : { fetch: async (...args) => Server.Default().app.fetch(...args) }), + }) + const cfg = yield* config.get() + const input: PluginInput = { + client, + project: ctx.project, + worktree: ctx.worktree, + directory: ctx.directory, + experimental_workspace: { + register(type: string, adapter: PluginWorkspaceAdapter) { + registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter) + }, + }, + get serverUrl(): URL { + return Server.url ?? new URL("http://localhost:4096") + }, + // @ts-expect-error + $: typeof Bun === "undefined" ? undefined : Bun.$, + } + + for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) { + const init = yield* Effect.tryPromise({ + try: () => plugin(input), + catch: errorMessage, + }).pipe( + Effect.tapError((error) => Effect.logError("failed to load internal plugin", { name: plugin.name, error })), + Effect.option, + ) + if (init._tag === "Some") hooks.push(init.value) + } + + const plugins = flags.pure ? [] : (cfg.plugin_origins ?? []) + if (flags.pure && cfg.plugin_origins?.length) { + } + if (plugins.length) yield* config.waitForDependencies() + + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: plugins, + kind: "server", + report: { + start(candidate) {}, + missing(candidate, _retry, message) {}, + error(candidate, _retry, stage, error, resolved) { + const spec = candidate.plan.spec + const cause = error instanceof Error ? (error.cause ?? error) : error + const message = stage === "load" ? errorMessage(error) : errorMessage(cause) + + if (stage === "install") { + const parsed = parsePluginSpecifier(spec) + publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) + return + } + + if (stage === "compatibility") { + publishPluginError(`Plugin ${spec} skipped: ${message}`) + return + } + + if (stage === "entry") { + publishPluginError(`Failed to load plugin ${spec}: ${message}`) + return + } + + publishPluginError(`Failed to load plugin ${spec}: ${message}`) + }, + }, + }), + ) + for (const load of loaded) { + if (!load) continue + + // Keep plugin execution sequential so hook registration and execution + // order remains deterministic across plugin runs. + yield* Effect.tryPromise({ + try: () => applyPlugin(load, input, hooks), + catch: (err) => { + const message = errorMessage(err) + return message + }, + }).pipe( + Effect.tapError((error) => Effect.logError("failed to load plugin", { path: load.spec, error })), + Effect.catch(() => { + // TODO: make proper events for this + // events.publish(Session.Event.Error, { + // error: new NamedError.Unknown({ + // message: `Failed to load plugin ${load.spec}: ${message}`, + // }).toObject(), + // }) + return Effect.void + }), + ) + } + + // Notify plugins of current config + for (const hook of hooks) { + yield* Effect.tryPromise({ + try: () => Promise.resolve((hook as any).config?.(cfg)), + catch: errorMessage, + }).pipe( + Effect.tapError((error) => Effect.logError("plugin config hook failed", { error })), + Effect.ignore, + ) + } + + const unsubscribe = yield* events.listen((event) => { + if (event.location?.directory !== ctx.directory) return Effect.void + return Effect.sync(() => { + for (const hook of hooks) { + void hook["event"]?.({ event: { id: event.id, type: event.type, properties: event.data } as any }) + } + }) + }) + yield* Effect.addFinalizer(() => unsubscribe) + + yield* Effect.addFinalizer(() => + Effect.forEach( + hooks, + (hook) => + Effect.tryPromise({ + try: () => Promise.resolve(hook.dispose?.()), + catch: errorMessage, + }).pipe( + Effect.tapError((error) => Effect.logError("plugin dispose hook failed", { error })), + Effect.ignore, + ), + { discard: true }, + ), + ) + + return { hooks } + }), + ) + + const trigger = Effect.fn("Plugin.trigger")(function* < + Name extends TriggerName, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >(name: Name, input: Input, output: Output) { + if (!name) return output + const s = yield* InstanceState.get(state) + for (const hook of s.hooks) { + const fn = hook[name] as any + if (!fn) continue + yield* Effect.promise(async () => fn(input, output)) + } + return output + }) + + const list = Effect.fn("Plugin.list")(function* () { + const s = yield* InstanceState.get(state) + return s.hooks + }) + + const init = Effect.fn("Plugin.init")(function* () { + yield* InstanceState.get(state) + }) + + return Service.of({ trigger, list, init }) + }), +) + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [EventV2Bridge.node, Config.node, RuntimeFlags.node], +}) + +export * as Plugin from "." diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts new file mode 100644 index 0000000000000000000000000000000000000000..f07b78bc36f7680e076a4b6584054aff8240dbc3 --- /dev/null +++ b/packages/opencode/src/plugin/install.ts @@ -0,0 +1,439 @@ +import path from "path" +import { + type ParseError as JsoncParseError, + applyEdits, + modify, + parse as parseJsonc, + printParseErrorCode, +} from "jsonc-parser" + +import * as ConfigPaths from "@/config/paths" +import { Global } from "@opencode-ai/core/global" +import { Filesystem } from "@/util/filesystem" +import { Flock } from "@opencode-ai/core/util/flock" +import { isRecord } from "@/util/record" + +import { parsePluginSpecifier, readPackageThemes, readPluginPackage, resolvePluginTarget } from "./shared" + +type Mode = "noop" | "add" | "replace" +type Kind = "server" | "tui" + +export type Target = { + kind: Kind + opts?: Record +} + +export type InstallDeps = { + resolve: (spec: string) => Promise +} + +export type PatchDeps = { + readText: (file: string) => Promise + write: (file: string, text: string) => Promise + exists: (file: string) => Promise + files: (dir: string, name: "opencode" | "tui") => string[] +} + +export type PatchInput = { + spec: string + targets: Target[] + force?: boolean + global?: boolean + vcs?: string + worktree: string + directory: string + config?: string +} + +type Ok = { + ok: true +} & T + +type Err = { + ok: false + code: C +} & T + +export type InstallResult = Ok<{ target: string }> | Err<"install_failed", { error: unknown }> + +export type ManifestResult = + | Ok<{ targets: Target[] }> + | Err<"manifest_read_failed", { file: string; error: unknown }> + | Err<"manifest_no_targets", { file: string }> + +export type PatchItem = { + kind: Kind + mode: Mode + file: string +} + +type PatchErr = + | Err<"invalid_json", { kind: Kind; file: string; line: number; col: number; parse: string }> + | Err<"patch_failed", { kind: Kind; error: unknown }> + +type PatchOne = Ok<{ item: PatchItem }> | PatchErr + +export type PatchResult = Ok<{ dir: string; items: PatchItem[] }> | (PatchErr & { dir: string }) + +const defaultInstallDeps: InstallDeps = { + resolve: (spec) => resolvePluginTarget(spec), +} + +const defaultPatchDeps: PatchDeps = { + readText: (file) => Filesystem.readText(file), + write: async (file, text) => { + await Filesystem.write(file, text) + }, + exists: (file) => Filesystem.exists(file), + files: (dir, name) => ConfigPaths.fileInDirectory(dir, name), +} + +function pluginSpec(item: unknown) { + if (typeof item === "string") return item + if (!Array.isArray(item)) return + if (typeof item[0] !== "string") return + return item[0] +} + +function pluginList(data: unknown) { + if (!data || typeof data !== "object" || Array.isArray(data)) return + const item = data as { plugin?: unknown } + if (!Array.isArray(item.plugin)) return + return item.plugin +} + +function exportValue(value: unknown): string | undefined { + if (typeof value === "string") { + const next = value.trim() + if (next) return next + return + } + if (!isRecord(value)) return + for (const key of ["import", "default"]) { + const next = value[key] + if (typeof next !== "string") continue + const hit = next.trim() + if (!hit) continue + return hit + } +} + +function exportOptions(value: unknown): Record | undefined { + if (!isRecord(value)) return + const config = value.config + if (!isRecord(config)) return + return config +} + +function exportTarget(pkg: Record, kind: Kind) { + const exports = pkg.exports + if (!isRecord(exports)) return + const value = exports[`./${kind}`] + const entry = exportValue(value) + if (!entry) return + return { + opts: exportOptions(value), + } +} + +function hasMainTarget(pkg: Record) { + const main = pkg.main + if (typeof main !== "string") return false + return Boolean(main.trim()) +} + +function packageTargets(pkg: { json: Record; dir: string; pkg: string }) { + const spec = + typeof pkg.json.name === "string" && pkg.json.name.trim().length > 0 ? pkg.json.name.trim() : path.basename(pkg.dir) + const targets: Target[] = [] + const server = exportTarget(pkg.json, "server") + if (server) { + targets.push({ kind: "server", opts: server.opts }) + } else if (hasMainTarget(pkg.json)) { + targets.push({ kind: "server" }) + } + + const tui = exportTarget(pkg.json, "tui") + if (tui) { + targets.push({ kind: "tui", opts: tui.opts }) + } + + if (!targets.some((item) => item.kind === "tui") && readPackageThemes(spec, pkg).length) { + targets.push({ kind: "tui" }) + } + + return targets +} + +function patch(text: string, path: Array, value: unknown, insert = false) { + return applyEdits( + text, + modify(text, path, value, { + formattingOptions: { + tabSize: 2, + insertSpaces: true, + }, + isArrayInsertion: insert, + }), + ) +} + +function patchPluginList( + text: string, + list: unknown[] | undefined, + spec: string, + next: unknown, + force = false, +): { mode: Mode; text: string } { + const pkg = parsePluginSpecifier(spec).pkg + const rows = (list ?? []).map((item, i) => ({ + item, + i, + spec: pluginSpec(item), + })) + const dup = rows.filter((item) => { + if (!item.spec) return false + if (item.spec === spec) return true + if (item.spec.startsWith("file://")) return false + return parsePluginSpecifier(item.spec).pkg === pkg + }) + + if (!dup.length) { + if (!list) { + return { + mode: "add", + text: patch(text, ["plugin"], [next]), + } + } + return { + mode: "add", + text: patch(text, ["plugin", list.length], next, true), + } + } + + if (!force) { + return { + mode: "noop", + text, + } + } + + const keep = dup[0] + if (!keep) { + return { + mode: "noop", + text, + } + } + + if (dup.length === 1 && keep.spec === spec) { + return { + mode: "noop", + text, + } + } + + let out = text + if (typeof keep.item === "string") { + out = patch(out, ["plugin", keep.i], next) + } + if (Array.isArray(keep.item) && typeof keep.item[0] === "string") { + out = patch(out, ["plugin", keep.i, 0], spec) + } + + const del = dup + .map((item) => item.i) + .filter((i) => i !== keep.i) + .sort((a, b) => b - a) + + for (const i of del) { + out = patch(out, ["plugin", i], undefined) + } + + return { + mode: "replace", + text: out, + } +} + +export async function installPlugin(spec: string, dep: InstallDeps = defaultInstallDeps): Promise { + const target = await dep.resolve(spec).then( + (item) => ({ + ok: true as const, + item, + }), + (error: unknown) => ({ + ok: false as const, + error, + }), + ) + if (!target.ok) { + return { + ok: false, + code: "install_failed", + error: target.error, + } + } + return { + ok: true, + target: target.item, + } +} + +export async function readPluginManifest(target: string): Promise { + const pkg = await readPluginPackage(target).then( + (item) => ({ + ok: true as const, + item, + }), + (error: unknown) => ({ + ok: false as const, + error, + }), + ) + if (!pkg.ok) { + return { + ok: false, + code: "manifest_read_failed", + file: target, + error: pkg.error, + } + } + + const targets = await Promise.resolve() + .then(() => packageTargets(pkg.item)) + .then( + (item) => ({ ok: true as const, item }), + (error: unknown) => ({ ok: false as const, error }), + ) + + if (!targets.ok) { + return { + ok: false, + code: "manifest_read_failed", + file: pkg.item.pkg, + error: targets.error, + } + } + + if (!targets.item.length) { + return { + ok: false, + code: "manifest_no_targets", + file: pkg.item.pkg, + } + } + + return { + ok: true, + targets: targets.item, + } +} + +function patchDir(input: PatchInput) { + if (input.global) return input.config ?? Global.Path.config + const git = input.vcs === "git" && input.worktree !== "/" + const root = git ? input.worktree : input.directory + return path.join(root, ".opencode") +} + +function patchName(kind: Kind): "opencode" | "tui" { + if (kind === "server") return "opencode" + return "tui" +} + +async function patchOne(dir: string, target: Target, spec: string, force: boolean, dep: PatchDeps): Promise { + const name = patchName(target.kind) + await using _ = await Flock.acquire(`plug-config:${Filesystem.resolve(path.join(dir, name))}`) + + const files = dep.files(dir, name) + let cfg = files[0] + for (const file of files) { + if (!(await dep.exists(file))) continue + cfg = file + break + } + + const src = await dep.readText(cfg).catch((err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") return "{}" + return err + }) + if (src instanceof Error) { + return { + ok: false, + code: "patch_failed", + kind: target.kind, + error: src, + } + } + const text = src.trim() ? src : "{}" + + const errs: JsoncParseError[] = [] + const data = parseJsonc(text, errs, { allowTrailingComma: true }) + if (errs.length) { + const err = errs[0] + const lines = text.substring(0, err.offset).split("\n") + return { + ok: false, + code: "invalid_json", + kind: target.kind, + file: cfg, + line: lines.length, + col: lines[lines.length - 1].length + 1, + parse: printParseErrorCode(err.error), + } + } + + const list = pluginList(data) + const item = target.opts ? ([spec, target.opts] as const) : spec + const out = patchPluginList(text, list, spec, item, force) + if (out.mode === "noop") { + return { + ok: true, + item: { + kind: target.kind, + mode: out.mode, + file: cfg, + }, + } + } + + const write = await dep.write(cfg, out.text).catch((error: unknown) => error) + if (write instanceof Error) { + return { + ok: false, + code: "patch_failed", + kind: target.kind, + error: write, + } + } + + return { + ok: true, + item: { + kind: target.kind, + mode: out.mode, + file: cfg, + }, + } +} + +export async function patchPluginConfig(input: PatchInput, dep: PatchDeps = defaultPatchDeps): Promise { + const dir = patchDir(input) + const items: PatchItem[] = [] + for (const target of input.targets) { + const hit = await patchOne(dir, target, input.spec, Boolean(input.force), dep) + if (!hit.ok) { + return { + ...hit, + dir, + } + } + items.push(hit.item) + } + return { + ok: true, + dir, + items, + } +} diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts new file mode 100644 index 0000000000000000000000000000000000000000..94876b4d0cb8104de39fba7e5efb8042ba4a73db --- /dev/null +++ b/packages/opencode/src/plugin/loader.ts @@ -0,0 +1,237 @@ +import { + checkPluginCompatibility, + createPluginEntry, + isDeprecatedPlugin, + pluginSource, + resolvePluginTarget, + type PluginKind, + type PluginPackage, + type PluginSource, +} from "./shared" +import { ConfigPlugin } from "@/config/plugin" +import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" +import { InstallationVersion } from "@opencode-ai/core/installation/version" + +export namespace PluginLoader { + // A normalized plugin declaration derived from config before any filesystem or npm work happens. + export type Plan = { + spec: string + options: ConfigPluginV1.Options | undefined + deprecated: boolean + } + + // A plugin that has been resolved to a concrete target and entrypoint on disk. + export type Resolved = Plan & { + source: PluginSource + target: string + entry: string + pkg?: PluginPackage + } + + // A plugin target we could inspect, but which does not expose the requested kind of entrypoint. + export type Missing = Plan & { + source: PluginSource + target: string + pkg?: PluginPackage + message: string + } + + // A resolved plugin whose module has been imported successfully. + export type Loaded = Resolved & { + mod: Record + } + + type Candidate = { origin: ConfigPlugin.Origin; plan: Plan } + type Report = { + // Called before each attempt so callers can log initial load attempts and retries uniformly. + start?: (candidate: Candidate, retry: boolean) => void + // Called when the package exists but does not provide the requested entrypoint. + missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void + // Called for operational failures such as install, compatibility, or dynamic import errors. + error?: ( + candidate: Candidate, + retry: boolean, + stage: "install" | "entry" | "compatibility" | "load", + error: unknown, + resolved?: Resolved, + ) => void + } + + type AttemptResult = { + value?: R + retry: boolean + } + + function errorMessage(error: unknown) { + if (!error || typeof error !== "object") return "" + const message = "message" in error && typeof error.message === "string" ? error.message : "" + return message + } + + function isRetryableResolveError(stage: "install" | "entry" | "compatibility", error: unknown) { + if (stage !== "install") return false + return errorMessage(error).includes("missing package.json or index file") + } + + // Normalize a config item into the loader's internal representation. + function plan(item: ConfigPluginV1.Spec): Plan { + const spec = ConfigPlugin.pluginSpecifier(item) + return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) } + } + + // Resolve a configured plugin into a concrete entrypoint that can later be imported. + // + // The stages here intentionally separate install/target resolution, entrypoint detection, + // and compatibility checks so callers can report the exact reason a plugin was skipped. + export async function resolve( + plan: Plan, + kind: PluginKind, + ): Promise< + | { ok: true; value: Resolved } + | { ok: false; stage: "missing"; value: Missing } + | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } + > { + // First make sure the plugin exists locally, installing npm plugins on demand. + let target = "" + try { + target = await resolvePluginTarget(plan.spec) + } catch (error) { + return { ok: false, stage: "install", error } + } + if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) } + + // Then inspect the target for the requested server/tui entrypoint. + let base + try { + base = await createPluginEntry(plan.spec, target, kind) + } catch (error) { + return { ok: false, stage: "entry", error } + } + if (!base.entry) + return { + ok: false, + stage: "missing", + value: { + ...plan, + source: base.source, + target: base.target, + pkg: base.pkg, + message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`, + }, + } + + // npm plugins can declare which opencode versions they support; file plugins are treated + // as local development code and skip this compatibility gate. + if (base.source === "npm") { + try { + await checkPluginCompatibility(base.target, InstallationVersion, base.pkg) + } catch (error) { + return { ok: false, stage: "compatibility", error } + } + } + return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } } + } + + // Import the resolved module only after all earlier validation has succeeded. + export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> { + let mod + try { + mod = await import(row.entry) + } catch (error) { + return { ok: false, error } + } + if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) } + return { ok: true, value: { ...row, mod } } + } + + // Run one candidate through the full pipeline: resolve, optionally surface a missing entry, + // import the module, and finally let the caller transform the loaded plugin into any result type. + async function attempt( + candidate: Candidate, + kind: PluginKind, + retry: boolean, + finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise) | undefined, + missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise) | undefined, + report: Report | undefined, + ): Promise> { + const plan = candidate.plan + const filePlugin = pluginSource(plan.spec) === "file" + + // Deprecated plugin packages are silently ignored because they are now built in. + if (plan.deprecated) return { retry: false } + + report?.start?.(candidate, retry) + + const resolved = await resolve(plan, kind) + if (!resolved.ok) { + if (resolved.stage === "missing") { + // Missing entrypoints are handled separately so callers can still inspect package metadata, + // for example to load theme files from a tui plugin package that has no code entrypoint. + if (missing) { + const value = await missing(resolved.value, candidate.origin, retry) + if (value !== undefined) return { value, retry: false } + } + report?.missing?.(candidate, retry, resolved.value.message, resolved.value) + return { retry: false } + } + report?.error?.(candidate, retry, resolved.stage, resolved.error) + return { retry: filePlugin && isRetryableResolveError(resolved.stage, resolved.error) } + } + + const loaded = await load(resolved.value) + if (!loaded.ok) { + report?.error?.(candidate, retry, "load", loaded.error, resolved.value) + return { retry: false } + } + + // The default behavior is to return the successfully loaded plugin as-is, but callers can + // provide a finisher to adapt the result into a more specific runtime shape. + if (!finish) return { value: loaded.value as R, retry: false } + const value = await finish(loaded.value, candidate.origin, retry) + return { value, retry: false } + } + + type Input = { + items: ConfigPlugin.Origin[] + kind: PluginKind + wait?: () => Promise + finish?: (load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise + missing?: (value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise + report?: Report + } + + // Resolve and load all configured plugins in parallel. + // + // If `wait` is provided, file-based plugins with retryable pre-import setup failures are retried + // once after the caller finishes preparing dependencies. Once dynamic import runs, failures are + // treated as permanent for this process because Bun caches failed module resolution. + export async function loadExternal(input: Input): Promise { + const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) })) + const list: Array>> = [] + for (const candidate of candidates) { + list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report)) + } + const out = await Promise.all(list) + if (input.wait) { + let deps: Promise | undefined + for (let i = 0; i < candidates.length; i++) { + const previous = out[i] + if (previous?.value !== undefined) continue + if (previous?.retry !== true) continue + + // Only pre-import file plugin setup failures are retried. Bun caches failed dynamic imports, + // so dependency waiting cannot fix load/build/runtime/shape failures in this process. + const candidate = candidates[i] + if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue + deps ??= input.wait() + await deps + out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report) + } + } + + // Drop skipped/failed entries while preserving the successful result order. + const ready: R[] = [] + for (const item of out) if (item.value !== undefined) ready.push(item.value) + return ready + } +} diff --git a/packages/opencode/src/plugin/meta.ts b/packages/opencode/src/plugin/meta.ts new file mode 100644 index 0000000000000000000000000000000000000000..54f784d1791ab4795c0edb94e1d30e6f9a815e05 --- /dev/null +++ b/packages/opencode/src/plugin/meta.ts @@ -0,0 +1,188 @@ +import path from "path" +import { fileURLToPath } from "url" + +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Filesystem } from "@/util/filesystem" +import { Flock } from "@opencode-ai/core/util/flock" + +import { parsePluginSpecifier, pluginSource } from "./shared" + +type Source = "file" | "npm" + +export type Theme = { + src: string + dest: string + mtime?: number + size?: number +} + +export type Entry = { + id: string + source: Source + spec: string + target: string + requested?: string + version?: string + modified?: number + first_time: number + last_time: number + time_changed: number + load_count: number + fingerprint: string + themes?: Record +} + +export type State = "first" | "updated" | "same" + +export type Touch = { + spec: string + target: string + id: string +} + +type Store = Record +type Core = Omit +type Row = Touch & { core: Core } + +function storePath() { + return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json") +} + +function lock(file: string) { + return `plugin-meta:${file}` +} + +function fileTarget(spec: string, target: string) { + if (spec.startsWith("file://")) return fileURLToPath(spec) + if (target.startsWith("file://")) return fileURLToPath(target) + return +} + +async function modifiedAt(file: string) { + const stat = await Filesystem.statAsync(file) + if (!stat) return + const mtime = stat.mtimeMs + return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime) +} + +function resolvedTarget(target: string) { + if (target.startsWith("file://")) return fileURLToPath(target) + return target +} + +async function npmVersion(target: string) { + const resolved = resolvedTarget(target) + const stat = await Filesystem.statAsync(resolved) + const dir = stat?.isDirectory() ? resolved : path.dirname(resolved) + return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json")) + .then((item) => item.version) + .catch(() => undefined) +} + +async function entryCore(item: Touch): Promise { + const spec = item.spec + const target = item.target + const source = pluginSource(spec) + if (source === "file") { + const file = fileTarget(spec, target) + return { + id: item.id, + source, + spec, + target, + modified: file ? await modifiedAt(file) : undefined, + } + } + + return { + id: item.id, + source, + spec, + target, + requested: parsePluginSpecifier(spec).version, + version: await npmVersion(target), + } +} + +function fingerprint(value: Core) { + if (value.source === "file") return [value.target, value.modified ?? ""].join("|") + return [value.target, value.requested ?? "", value.version ?? ""].join("|") +} + +async function read(file: string): Promise { + return Filesystem.readJson(file).catch(() => ({}) as Store) +} + +async function row(item: Touch): Promise { + return { + ...item, + core: await entryCore(item), + } +} + +function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } { + const entry: Entry = { + ...core, + first_time: prev?.first_time ?? now, + last_time: now, + time_changed: prev?.time_changed ?? now, + load_count: (prev?.load_count ?? 0) + 1, + fingerprint: fingerprint(core), + themes: prev?.themes, + } + const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated" + if (state === "updated") entry.time_changed = now + return { + state, + entry, + } +} + +export async function touchMany(items: Touch[]): Promise> { + if (!items.length) return [] + const file = storePath() + const rows = await Promise.all(items.map((item) => row(item))) + + return Flock.withLock(lock(file), async () => { + const store = await read(file) + const now = Date.now() + const out: Array<{ state: State; entry: Entry }> = [] + for (const item of rows) { + const hit = next(store[item.id], item.core, now) + store[item.id] = hit.entry + out.push(hit) + } + await Filesystem.writeJson(file, store) + return out + }) +} + +export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> { + return touchMany([{ spec, target, id }]).then((item) => { + const hit = item[0] + if (hit) return hit + throw new Error("Failed to touch plugin metadata.") + }) +} + +export async function setTheme(id: string, name: string, theme: Theme): Promise { + const file = storePath() + await Flock.withLock(lock(file), async () => { + const store = await read(file) + const entry = store[id] + if (!entry) return + entry.themes = { + ...entry.themes, + [name]: theme, + } + await Filesystem.writeJson(file, store) + }) +} + +export async function list(): Promise { + const file = storePath() + return Flock.withLock(lock(file), async () => read(file)) +} + +export * as PluginMeta from "./meta" diff --git a/packages/opencode/src/plugin/pty-environment.ts b/packages/opencode/src/plugin/pty-environment.ts new file mode 100644 index 0000000000000000000000000000000000000000..67321372f1eef59bc7d055e8de4f58a41bdaced3 --- /dev/null +++ b/packages/opencode/src/plugin/pty-environment.ts @@ -0,0 +1,24 @@ +export * as PluginPtyEnvironment from "./pty-environment" + +import { PtyEnvironment } from "@opencode-ai/server/pty-environment" +import { Effect, Layer } from "effect" +import { InstanceStore } from "@/project/instance-store" +import { Plugin } from "." + +export const layer = Layer.effect( + PtyEnvironment.Service, + Effect.gen(function* () { + const plugin = yield* Plugin.Service + const instances = yield* InstanceStore.Service + return PtyEnvironment.Service.of({ + get: Effect.fn("PtyEnvironment.get")(function* (input) { + return yield* instances.provide( + { directory: input.directory }, + plugin + .trigger("shell.env", { cwd: input.cwd }, { env: {} as Record }) + .pipe(Effect.map((result) => result.env)), + ) + }), + }) + }), +) diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a519359bde60bca0e7a3080b00c8d05e2bec416 --- /dev/null +++ b/packages/opencode/src/plugin/shared.ts @@ -0,0 +1,323 @@ +import path from "path" +import { fileURLToPath, pathToFileURL } from "url" +import npa from "npm-package-arg" +import semver from "semver" +import { Filesystem } from "@/util/filesystem" +import { isRecord } from "@/util/record" +import { Npm } from "@opencode-ai/core/npm" + +// Old npm package names for plugins that are now built-in +export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"] + +export function isDeprecatedPlugin(spec: string) { + return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg)) +} + +function parse(spec: string) { + try { + return npa(spec) + } catch {} +} + +export function parsePluginSpecifier(spec: string) { + const hit = parse(spec) + if (hit?.type === "alias" && !hit.name) { + const sub = (hit as npa.AliasResult).subSpec + if (sub?.name) { + const version = !sub.rawSpec || sub.rawSpec === "*" ? "latest" : sub.rawSpec + return { pkg: sub.name, version } + } + } + if (!hit?.name) return { pkg: spec, version: "" } + if (hit.raw === hit.name) return { pkg: hit.name, version: "latest" } + return { pkg: hit.name, version: hit.rawSpec } +} + +export type PluginSource = "file" | "npm" +export type PluginKind = "server" | "tui" +type PluginMode = "strict" | "detect" + +export type PluginPackage = { + dir: string + pkg: string + json: Record +} + +export type PluginEntry = { + spec: string + source: PluginSource + target: string + pkg?: PluginPackage + entry?: string +} + +const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"] + +export function pluginSource(spec: string): PluginSource { + if (isPathPluginSpec(spec)) return "file" + return "npm" +} + +function resolveExportPath(raw: string, dir: string) { + if (raw.startsWith("file://")) return fileURLToPath(raw) + if (path.isAbsolute(raw)) return raw + return path.resolve(dir, raw) +} + +function isAbsolutePath(raw: string) { + return path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) +} + +function extractExportValue(value: unknown): string | undefined { + if (typeof value === "string") return value + if (!isRecord(value)) return undefined + for (const key of ["import", "default"]) { + const nested = value[key] + if (typeof nested === "string") return nested + } + return undefined +} + +function packageMain(pkg: PluginPackage) { + const value = pkg.json.main + if (typeof value !== "string") return + const next = value.trim() + if (!next) return + return next +} + +function resolvePackageFile(spec: string, raw: string, kind: string, pkg: PluginPackage) { + const resolved = resolveExportPath(raw, pkg.dir) + const root = Filesystem.resolve(pkg.dir) + const next = Filesystem.resolve(resolved) + if (!Filesystem.contains(root, next)) { + throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`) + } + return next +} + +function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) { + return pathToFileURL(resolvePackageFile(spec, raw, kind, pkg)).href +} + +function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) { + const exports = pkg.json.exports + if (isRecord(exports)) { + const raw = extractExportValue(exports[`./${kind}`]) + if (raw) return resolvePackagePath(spec, raw, kind, pkg) + } + + if (kind !== "server") return + const main = packageMain(pkg) + if (!main) return + return resolvePackagePath(spec, main, kind, pkg) +} + +function targetPath(target: string) { + if (target.startsWith("file://")) return fileURLToPath(target) + if (path.isAbsolute(target)) return target +} + +async function resolveDirectoryIndex(dir: string) { + for (const name of INDEX_FILES) { + const file = path.join(dir, name) + if (await Filesystem.exists(file)) return file + } +} + +async function resolveTargetDirectory(target: string) { + const file = targetPath(target) + if (!file) return + const stat = await Filesystem.statAsync(file) + if (!stat?.isDirectory()) return + return file +} + +async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind, pkg?: PluginPackage) { + const source = pluginSource(spec) + const hit = + pkg ?? (source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined)) + if (!hit) return target + + const entry = resolvePackageEntrypoint(spec, kind, hit) + if (entry) return entry + + const dir = await resolveTargetDirectory(target) + + if (kind === "tui") { + if (source === "file" && dir) { + const index = await resolveDirectoryIndex(dir) + if (index) return pathToFileURL(index).href + } + + if (source === "npm") return + if (dir) return + + return target + } + + if (dir && isRecord(hit.json.exports)) { + if (source === "file") { + const index = await resolveDirectoryIndex(dir) + if (index) return pathToFileURL(index).href + } + + return + } + + return target +} + +export function isPathPluginSpec(spec: string) { + return spec.startsWith("file://") || spec.startsWith(".") || isAbsolutePath(spec) +} + +export async function resolvePathPluginTarget(spec: string) { + const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec + const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw) + const stat = await Filesystem.statAsync(file) + if (!stat?.isDirectory()) { + if (spec.startsWith("file://")) return spec + return pathToFileURL(file).href + } + + if (await Filesystem.exists(path.join(file, "package.json"))) { + return pathToFileURL(file).href + } + + const index = await resolveDirectoryIndex(file) + if (index) return pathToFileURL(index).href + + throw new Error(`Plugin directory ${file} is missing package.json or index file`) +} + +export async function checkPluginCompatibility(target: string, opencodeVersion: string, pkg?: PluginPackage) { + if (!semver.valid(opencodeVersion) || semver.major(opencodeVersion) === 0) return + const hit = pkg ?? (await readPluginPackage(target).catch(() => undefined)) + if (!hit) return + const engines = hit.json.engines + if (!isRecord(engines)) return + const range = engines.opencode + if (typeof range !== "string") return + if (!semver.satisfies(opencodeVersion, range)) { + throw new Error(`Plugin requires opencode ${range} but running ${opencodeVersion}`) + } +} + +export async function resolvePluginTarget(spec: string) { + if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec) + const hit = parse(spec) + const pkg = hit?.name && hit.raw === hit.name ? `${hit.name}@latest` : spec + const result = await Npm.add(pkg) + return result.directory +} + +export async function readPluginPackage(target: string): Promise { + const file = target.startsWith("file://") ? fileURLToPath(target) : target + const stat = await Filesystem.statAsync(file) + const dir = stat?.isDirectory() ? file : path.dirname(file) + const pkg = path.join(dir, "package.json") + const json = await Filesystem.readJson>(pkg) + return { dir, pkg, json } +} + +export async function createPluginEntry(spec: string, target: string, kind: PluginKind): Promise { + const source = pluginSource(spec) + const pkg = + source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined) + const entry = await resolvePluginEntrypoint(spec, target, kind, pkg) + return { + spec, + source, + target, + pkg, + entry, + } +} + +export function readPackageThemes(spec: string, pkg: PluginPackage) { + const field = pkg.json["oc-themes"] + if (field === undefined) return [] + if (!Array.isArray(field)) { + throw new TypeError(`Plugin ${spec} has invalid oc-themes field`) + } + + const list = field.map((item) => { + if (typeof item !== "string") { + throw new TypeError(`Plugin ${spec} has invalid oc-themes entry`) + } + + const raw = item.trim() + if (!raw) { + throw new TypeError(`Plugin ${spec} has empty oc-themes entry`) + } + if (raw.startsWith("file://") || isAbsolutePath(raw)) { + throw new TypeError(`Plugin ${spec} oc-themes entry must be relative: ${item}`) + } + + return resolvePackageFile(spec, raw, "oc-themes", pkg) + }) + + return Array.from(new Set(list)) +} + +export function readPluginId(id: unknown, spec: string) { + if (id === undefined) return + if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`) + const value = id.trim() + if (!value) throw new TypeError(`Plugin ${spec} has an empty id`) + return value +} + +export function readV1Plugin( + mod: Record, + spec: string, + kind: PluginKind, + mode: PluginMode = "strict", +) { + const value = mod.default + if (!isRecord(value)) { + if (mode === "detect") return + throw new TypeError(`Plugin ${spec} must default export an object with ${kind}()`) + } + if (mode === "detect" && !("id" in value) && !("server" in value) && !("tui" in value)) return + + const server = "server" in value ? value.server : undefined + const tui = "tui" in value ? value.tui : undefined + if (server !== undefined && typeof server !== "function") { + throw new TypeError(`Plugin ${spec} has invalid server export`) + } + if (tui !== undefined && typeof tui !== "function") { + throw new TypeError(`Plugin ${spec} has invalid tui export`) + } + if (server !== undefined && tui !== undefined) { + throw new TypeError(`Plugin ${spec} must default export either server() or tui(), not both`) + } + if (kind === "server" && server === undefined) { + throw new TypeError(`Plugin ${spec} must default export an object with server()`) + } + if (kind === "tui" && tui === undefined) { + throw new TypeError(`Plugin ${spec} must default export an object with tui()`) + } + + return value +} + +export async function resolvePluginId( + source: PluginSource, + spec: string, + target: string, + id: string | undefined, + pkg?: PluginPackage, +) { + if (source === "file") { + if (id) return id + throw new TypeError(`Path plugin ${spec} must export id`) + } + if (id) return id + const hit = pkg ?? (await readPluginPackage(target)) + if (typeof hit.json.name !== "string" || !hit.json.name.trim()) { + throw new TypeError(`Plugin package ${hit.pkg} is missing name`) + } + return hit.json.name.trim() +} diff --git a/packages/opencode/src/plugin/snowflake-cortex.ts b/packages/opencode/src/plugin/snowflake-cortex.ts new file mode 100644 index 0000000000000000000000000000000000000000..09f107ed7b969340180e8a7c88aa3fb879331cb4 --- /dev/null +++ b/packages/opencode/src/plugin/snowflake-cortex.ts @@ -0,0 +1,507 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { OAUTH_DUMMY_KEY } from "../auth" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" +import { createServer } from "http" +import open from "open" + +const OAUTH_CLIENT_ID = "LOCAL_APPLICATION" +const OAUTH_CALLBACK_HOST = "127.0.0.1" +const OAUTH_CALLBACK_PATH = "/" +const OAUTH_TIMEOUT_MS = 5 * 60 * 1000 +const ACCESS_TOKEN_REFRESH_SKEW_MS = 120_000 + +interface PkceCodes { + verifier: string + challenge: string +} + +interface TokenResponse { + access_token: string + refresh_token?: string + expires_in?: number + token_type?: string +} + +interface PendingOAuth { + account: string + state: string + pkce: PkceCodes + resolve: (tokens: TokenResponse) => void + reject: (error: Error) => void +} + +let oauthServer: ReturnType | undefined +let pendingOAuth: PendingOAuth | undefined +let oauthServerPort: number | undefined + +function normalizeAccount(input: string) { + return input + .trim() + .replace(/^https?:\/\//, "") + .replace(/\.snowflakecomputing\.com\/?$/, "") + .replace(/\/+$/, "") +} + +function generateRandomString(length: number) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + return Array.from(crypto.getRandomValues(new Uint8Array(length))) + .map((b) => chars[b % chars.length]) + .join("") +} + +function base64UrlEncode(buffer: ArrayBuffer) { + const binary = String.fromCharCode(...new Uint8Array(buffer)) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +async function generatePKCE(): Promise { + const verifier = generateRandomString(64) + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)) + return { + verifier, + challenge: base64UrlEncode(hash), + } +} + +function callbackUrl() { + if (!oauthServerPort) throw new Error("Snowflake OAuth callback server is not running") + return `http://${OAUTH_CALLBACK_HOST}:${oauthServerPort}${OAUTH_CALLBACK_PATH}` +} + +export function oauthScope(role: string | undefined) { + if (!role) return "refresh_token" + return /^[-_A-Za-z0-9]+$/.test(role) + ? `refresh_token session:role:${role}` + : `refresh_token session:role-encoded:${encodeURIComponent(role)}` +} + +function authHeaders() { + return { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + } +} + +function authBasicHeader() { + return `Basic ${Buffer.from(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_ID}`).toString("base64")}` +} + +function buildAuthorizeUrl(account: string, role: string | undefined, state: string, pkce: PkceCodes) { + const scope = oauthScope(role) + const params = new URLSearchParams({ + client_id: OAUTH_CLIENT_ID, + response_type: "code", + redirect_uri: callbackUrl(), + scope, + state, + code_challenge: pkce.challenge, + code_challenge_method: "S256", + }) + return `https://${account}.snowflakecomputing.com/oauth/authorize?${params.toString()}` +} + +async function exchangeCodeForToken(account: string, code: string, pkce: PkceCodes) { + const response = await fetch(`https://${account}.snowflakecomputing.com/oauth/token-request`, { + method: "POST", + headers: { + ...authHeaders(), + Authorization: authBasicHeader(), + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: callbackUrl(), + client_id: OAUTH_CLIENT_ID, + code_verifier: pkce.verifier, + }).toString(), + }) + + if (!response.ok) { + const detail = await response.text().catch(() => "") + throw new Error(`Snowflake token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`) + } + + const token = (await response.json()) as TokenResponse + if (!token.access_token) throw new Error("Snowflake token response did not include access_token") + if (!token.refresh_token) { + throw new Error( + "Snowflake token response did not include refresh_token. Ensure integration issues refresh tokens and scope includes refresh_token.", + ) + } + return token +} + +async function refreshAccessToken(account: string, refreshToken: string) { + const response = await fetch(`https://${account}.snowflakecomputing.com/oauth/token-request`, { + method: "POST", + headers: { + ...authHeaders(), + Authorization: authBasicHeader(), + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: OAUTH_CLIENT_ID, + }).toString(), + }) + + if (!response.ok) { + const detail = await response.text().catch(() => "") + throw new Error(`Snowflake token refresh failed (${response.status})${detail ? `: ${detail}` : ""}`) + } + + const token = (await response.json()) as TokenResponse + if (!token.access_token) throw new Error("Snowflake refresh response did not include access_token") + return token +} + +async function startOAuthServer() { + if (oauthServer) return + + oauthServer = createServer((req, res) => { + const host = req.headers.host || `${OAUTH_CALLBACK_HOST}:${oauthServerPort ?? 0}` + const url = new URL(req.url || "/", `http://${host}`) + + if (url.pathname !== OAUTH_CALLBACK_PATH) { + res.writeHead(404) + res.end("Not found") + return + } + + const state = url.searchParams.get("state") + const code = url.searchParams.get("code") + const error = url.searchParams.get("error") + const errorDescription = url.searchParams.get("error_description") + + // CSRF guard: validate state before processing any callback + if (!pendingOAuth || state !== pendingOAuth.state) { + const message = "Invalid state - potential CSRF attack" + pendingOAuth?.reject(new Error(message)) + pendingOAuth = undefined + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(OauthCallbackPage.error(message, { provider: "Snowflake" })) + return + } + + const current = pendingOAuth + pendingOAuth = undefined + + if (error) { + const message = errorDescription || error + current.reject(new Error(message)) + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(OauthCallbackPage.error(message, { provider: "Snowflake" })) + return + } + + if (!code) { + const message = "Missing authorization code" + current.reject(new Error(message)) + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(OauthCallbackPage.error(message, { provider: "Snowflake" })) + return + } + + exchangeCodeForToken(current.account, code, current.pkce) + .then((tokens) => current.resolve(tokens)) + .catch((err) => current.reject(err instanceof Error ? err : new Error(String(err)))) + + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(OauthCallbackPage.success({ provider: "Snowflake" })) + }) + + await new Promise((resolve, reject) => { + oauthServer!.listen(0, OAUTH_CALLBACK_HOST, () => { + const address = oauthServer!.address() + if (!address || typeof address === "string") { + reject(new Error("Unable to resolve Snowflake OAuth callback port")) + return + } + oauthServerPort = address.port + resolve() + }) + oauthServer!.on("error", reject) + }) +} + +function stopOAuthServer() { + if (!oauthServer) return + oauthServer.close() + oauthServer = undefined + oauthServerPort = undefined +} + +function waitForOAuthCallback(account: string, pkce: PkceCodes, state: string): Promise { + if (pendingOAuth) { + pendingOAuth.reject(new Error("Superseded by a newer Snowflake authorize request")) + pendingOAuth = undefined + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (!pendingOAuth) return + pendingOAuth = undefined + stopOAuthServer() + reject(new Error("Snowflake OAuth callback timeout - authorization took too long")) + }, OAUTH_TIMEOUT_MS) + + pendingOAuth = { + account, + state, + pkce, + resolve: (tokens) => { + clearTimeout(timeout) + resolve(tokens) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + }, + } + }) +} + +export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise { + const prompts = [ + { + type: "text" as const, + key: "account", + message: "Snowflake Account Identifier", + placeholder: "myorg-myaccount", + validate: (value: string) => (value && value.trim().length > 0 ? undefined : "Required"), + }, + { + type: "text" as const, + key: "role", + message: "Snowflake Role (optional)", + placeholder: "PUBLIC", + }, + ] + + return { + auth: { + provider: "snowflake-cortex", + async loader(getAuth, _provider) { + let auth = await getAuth() + if (auth.type !== "oauth") return {} + + let refreshPromise: + | Promise<{ + access: string + refresh: string + expires: number + }> + | undefined + + const oauth = auth as typeof auth & { refresh: string; access: string; expires: number; accountId?: string } + + if (oauth.accountId && oauth.refresh && oauth.expires && oauth.expires <= Date.now()) { + try { + const tokens = await refreshAccessToken(oauth.accountId, oauth.refresh) + const refreshedRefresh = tokens.refresh_token || oauth.refresh + const refreshedExpires = Date.now() + (tokens.expires_in ?? 600) * 1000 + await _input.client.auth + .set({ + path: { id: "snowflake-cortex" }, + body: { + type: "oauth", + access: tokens.access_token, + refresh: refreshedRefresh, + expires: refreshedExpires, + ...(oauth.accountId && { accountId: oauth.accountId }), + }, + }) + .catch(() => {}) + } catch {} + } + + return { + apiKey: OAUTH_DUMMY_KEY, + async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { + let currentAuth = await getAuth() + if (currentAuth.type !== "oauth") return fetch(requestInput, init) + let currentOauth = currentAuth as typeof currentAuth & { + refresh: string + access: string + expires: number + accountId?: string + } + + if (!currentOauth.accountId) throw new Error("Snowflake OAuth auth is missing accountId") + const accountId = currentOauth.accountId + + const refresh = async () => { + if (!refreshPromise) { + const refreshToken = currentOauth.refresh + refreshPromise = refreshAccessToken(accountId, refreshToken) + .then(async (tokens) => { + const refreshedRefresh = tokens.refresh_token || refreshToken + const refreshedExpires = Date.now() + (tokens.expires_in ?? 600) * 1000 + await _input.client.auth + .set({ + path: { id: "snowflake-cortex" }, + body: { + type: "oauth", + access: tokens.access_token, + refresh: refreshedRefresh, + expires: refreshedExpires, + ...(accountId && { accountId }), + }, + }) + .catch(() => {}) + return { + access: tokens.access_token, + refresh: refreshedRefresh, + expires: refreshedExpires, + } + }) + .finally(() => { + refreshPromise = undefined + }) + } + + const refreshed = await refreshPromise + currentOauth = { ...currentOauth, ...refreshed } + } + + const prepareRequest = () => { + const headers = new Headers(requestInput instanceof Request ? requestInput.headers : undefined) + if (init?.headers) { + const entries = + init.headers instanceof Headers + ? init.headers.entries() + : Array.isArray(init.headers) + ? init.headers + : Object.entries(init.headers as Record) + for (const [key, value] of entries) { + if (value !== undefined) headers.set(key, String(value)) + } + } + headers.set("authorization", `Bearer ${currentOauth.access}`) + headers.set("User-Agent", `opencode/${InstallationVersion}`) + + let body = init?.body + if (body && typeof body === "string") { + try { + const parsed = JSON.parse(body) + if ("max_tokens" in parsed) { + parsed.max_completion_tokens = parsed.max_tokens + delete parsed.max_tokens + body = JSON.stringify(parsed) + } + } catch {} + } + + return { ...init, headers, body } + } + + const transformResponse = async (response: Response) => { + if (!response.ok && response.status === 400) { + try { + const errorData = await response.clone().json() + const errorMessage = String(errorData.message || errorData.error || "") + if (errorMessage.toLowerCase().includes("conversation complete")) { + return new Response( + JSON.stringify({ + choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }], + }), + { status: 200, headers: new Headers({ "content-type": "application/json" }) }, + ) + } + } catch {} + } + + if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) { + const reader = response.body.getReader() + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const stream = new ReadableStream({ + async pull(ctrl) { + const { done, value } = await reader.read() + if (done) { + ctrl.close() + return + } + const text = decoder.decode(value, { stream: true }) + ctrl.enqueue(encoder.encode(text.replace(/"role"\s*:\s*""/g, '"role":"assistant"'))) + }, + cancel() { + reader.cancel() + }, + }) + return new Response(stream, { headers: response.headers, status: response.status }) + } + + return response + } + + const expiresSoon = + !currentOauth.expires || + !currentOauth.access || + currentOauth.expires - Date.now() <= ACCESS_TOKEN_REFRESH_SKEW_MS + + if (expiresSoon) await refresh() + + const response = await fetch(requestInput, prepareRequest()) + + if (response.status === 401) { + await refresh() + return transformResponse(await fetch(requestInput, prepareRequest())) + } + + return transformResponse(response) + }, + } + }, + methods: [ + { + type: "oauth", + label: "Login with Snowflake (External Browser)", + prompts, + async authorize(inputs = {}) { + const account = normalizeAccount(inputs.account || "") + if (!account) throw new Error("Snowflake account is required") + + await startOAuthServer() + const pkce = await generatePKCE() + const state = generateRandomString(64) + const role = (inputs.role || "").trim() || undefined + const url = buildAuthorizeUrl(account, role, state, pkce) + const callbackPromise = waitForOAuthCallback(account, pkce, state) + await open(url).catch(() => undefined) + + return { + url, + instructions: + "Complete Snowflake sign-in in your browser. OpenCode will capture the OAuth callback and store the bearer token automatically.", + method: "auto" as const, + async callback() { + try { + const tokens = await callbackPromise + return { + type: "success" as const, + refresh: tokens.refresh_token!, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 600) * 1000, + accountId: account, + } + } catch { + return { type: "failed" as const } + } finally { + stopOAuthServer() + } + }, + } + }, + }, + { + type: "api", + label: "Paste PAT or bearer token manually", + prompts: prompts.filter((item) => item.key === "account"), + }, + ], + }, + } +} diff --git a/packages/opencode/src/plugin/xai.ts b/packages/opencode/src/plugin/xai.ts new file mode 100644 index 0000000000000000000000000000000000000000..a455121d6aedfac1ee9f68bd4f6d455ce5a86eba --- /dev/null +++ b/packages/opencode/src/plugin/xai.ts @@ -0,0 +1,339 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { OAUTH_DUMMY_KEY } from "../auth" +import { InstallationVersion } from "@opencode-ai/core/installation/version" + +// Public Grok-CLI OAuth client. +const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828" +const TOKEN_URL = "https://auth.x.ai/oauth2/token" +// RFC 8628 device authorization grant. Confirmed exposed by xAI's +// /.well-known/openid-configuration as `device_authorization_endpoint` +// with the matching `urn:ietf:params:oauth:grant-type:device_code` grant +// in `grant_types_supported`. This is the headless / VPS path: no +// loopback callback server, no SSH port forwarding, no inbound firewall +// holes — the user opens the URL on any device with a browser, types +// the short user_code, and the CLI long-polls the token endpoint. +const DEVICE_AUTHORIZATION_URL = "https://auth.x.ai/oauth2/device/code" +const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" +const SCOPE = "openid profile email offline_access grok-cli:access api:access" + +// Bounds for the device-code poll loop. xAI returns `interval` (seconds) +// but we floor it to avoid hammering and we add the spec's slow_down +// increment when xAI explicitly asks us to back off. +const DEVICE_CODE_DEFAULT_INTERVAL_MS = 5_000 +const DEVICE_CODE_MIN_INTERVAL_MS = 1_000 +const DEVICE_CODE_SLOW_DOWN_INCREMENT_MS = 5_000 +const DEVICE_CODE_DEFAULT_EXPIRES_MS = 5 * 60 * 1000 +const OAUTH_POLLING_SAFETY_MARGIN_MS = 3_000 + +// Refresh the access token a little before it actually expires so a single +// long-running tool call doesn't have to recover from a mid-flight 401. +const ACCESS_TOKEN_REFRESH_SKEW_MS = 120_000 + +interface XaiAuthPluginOptions { + tokenUrl?: string + deviceAuthorizationUrl?: string +} + +interface TokenResponse { + access_token: string + refresh_token: string + id_token?: string + token_type?: string + expires_in?: number + scope?: string +} + +function authHeaders() { + return { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + } +} + +// Parse the `exp` claim out of a JWT access_token without verifying the +// signature. We only use this to decide whether to proactively refresh, never +// to make trust decisions, so unsigned decode is safe. Returns false for +// opaque tokens (no JWT shape), which conservatively skips the proactive +// refresh and lets the 401-on-call path drive the refresh instead. +export function accessTokenIsExpiring( + token: string | undefined, + skewMs: number = ACCESS_TOKEN_REFRESH_SKEW_MS, +): boolean { + if (!token || typeof token !== "string") return false + const parts = token.split(".") + if (parts.length < 2) return false + try { + let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/") + while (payload.length % 4 !== 0) payload += "=" + const claims = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) + if (typeof claims?.exp !== "number") return false + return claims.exp * 1000 <= Date.now() + Math.max(0, skewMs) + } catch { + return false + } +} + +async function refreshAccessToken(refreshToken: string, options: XaiAuthPluginOptions = {}): Promise { + const response = await fetch(options.tokenUrl ?? TOKEN_URL, { + method: "POST", + headers: authHeaders(), + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }).toString(), + }) + if (!response.ok) { + const detail = await response.text().catch(() => "") + throw new Error(`xAI token refresh failed (${response.status})${detail ? `: ${detail}` : ""}`) + } + return response.json() as Promise +} + +export interface DeviceCodeResponse { + device_code: string + user_code: string + verification_uri: string + verification_uri_complete?: string + expires_in?: number + interval?: number +} + +interface DeviceTokenErrorBody { + error?: string + error_description?: string +} + +export async function requestDeviceCode(options: XaiAuthPluginOptions = {}): Promise { + const response = await fetch(options.deviceAuthorizationUrl ?? DEVICE_AUTHORIZATION_URL, { + method: "POST", + headers: authHeaders(), + body: new URLSearchParams({ + client_id: CLIENT_ID, + scope: SCOPE, + referrer: "opencode", + }).toString(), + }) + if (!response.ok) { + const detail = await response.text().catch(() => "") + throw new Error(`xAI device code request failed (${response.status})${detail ? `: ${detail}` : ""}`) + } + const json = (await response.json()) as DeviceCodeResponse + if (!json.device_code || !json.user_code || !json.verification_uri) { + throw new Error("xAI device code response is missing device_code / user_code / verification_uri") + } + return json +} + +// Default sleep used between device-code polls. Test-injectable so we can +// exercise authorization_pending / slow_down branches without real waits. +async function defaultSleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +// Normalize a server-supplied seconds value to milliseconds, falling back to +// the supplied default when the input is missing, non-positive, or not a +// finite number. Defends the polling loop against garbage like `NaN`, `"NaN"`, +// `null`, or `-5` from a misbehaving device-code endpoint — without this, +// a NaN interval would slip through `?? default` (NaN is typeof number), +// reach `setTimeout(_, NaN)` which is treated as 0, and busy-loop until the +// hard deadline. Matches the defensive normalization Codex uses for the same +// field (`parseInt(deviceData.interval) || 5`). +function positiveSecondsToMs(value: unknown, defaultMs: number): number { + const seconds = Number(value) + return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : defaultMs +} + +export async function pollDeviceCodeToken( + device: DeviceCodeResponse, + options: XaiAuthPluginOptions & { sleep?: (ms: number) => Promise; now?: () => number } = {}, +): Promise { + const sleep = options.sleep ?? defaultSleep + const now = options.now ?? (() => Date.now()) + const expiresInMs = positiveSecondsToMs(device.expires_in, DEVICE_CODE_DEFAULT_EXPIRES_MS) + const deadline = now() + expiresInMs + let intervalMs = Math.max( + positiveSecondsToMs(device.interval, DEVICE_CODE_DEFAULT_INTERVAL_MS), + DEVICE_CODE_MIN_INTERVAL_MS, + ) + + while (now() < deadline) { + const response = await fetch(options.tokenUrl ?? TOKEN_URL, { + method: "POST", + headers: authHeaders(), + body: new URLSearchParams({ + grant_type: DEVICE_CODE_GRANT_TYPE, + client_id: CLIENT_ID, + device_code: device.device_code, + }).toString(), + }) + if (response.ok) return (await response.json()) as TokenResponse + + const body = (await response.json().catch(() => ({}))) as DeviceTokenErrorBody + const remaining = Math.max(0, deadline - now()) + // RFC 8628 §3.5: authorization_pending = keep polling at the same + // interval; slow_down = bump the interval by ≥5s and keep polling. + // Anything else is terminal. + if (body.error === "authorization_pending") { + await sleep(Math.min(intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS, remaining)) + continue + } + if (body.error === "slow_down") { + intervalMs += DEVICE_CODE_SLOW_DOWN_INCREMENT_MS + await sleep(Math.min(intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS, remaining)) + continue + } + if (body.error === "access_denied" || body.error === "authorization_denied") { + throw new Error("xAI device authorization was denied") + } + if (body.error === "expired_token") { + throw new Error("xAI device code expired - please re-run login") + } + const detail = body.error_description ?? body.error ?? "" + throw new Error(`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`) + } + throw new Error("xAI device authorization timed out") +} + +interface RefreshResult { + access: string + refresh: string + expires: number +} + +export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOptions = {}): Promise { + return { + auth: { + provider: "xai", + async loader(getAuth) { + const auth = await getAuth() + if (auth.type !== "oauth") return {} + + // Single-flight refresh: collapse concurrent fetches from this loaded + // provider onto one HTTP call so we don't replay a rotating refresh_token. + let refreshPromise: Promise | undefined + + return { + // Dummy bearer keeps the AI SDK from bailing on "missing apiKey"; the + // real OAuth token is injected by the fetch override below. + // We intentionally do NOT set baseURL — @ai-sdk/xai already defaults + // to https://api.x.ai/v1 and overriding here would silently route + // around a user-configured gateway. + apiKey: OAUTH_DUMMY_KEY, + async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { + let currentAuth = await getAuth() + // Auth can flip from oauth to api mid-session (user re-runs + // /connect with a pasted key). When that happens, pass the + // request through untouched so the AI SDK's own apiKey-based + // Authorization header reaches xAI unmodified. + if (currentAuth.type !== "oauth") return fetch(requestInput, init) + + // Refresh either when the stored expires timestamp is within the + // skew window, or — for JWT access tokens — when the JWT exp + // claim itself is. The stored expires field is best-effort + // (xAI doesn't always return expires_in) so the JWT check is the + // load-bearing one for tokens that lack a fresh stored deadline. + const expiresSoon = + !currentAuth.expires || + currentAuth.expires - Date.now() <= ACCESS_TOKEN_REFRESH_SKEW_MS || + accessTokenIsExpiring(currentAuth.access) + if (expiresSoon) { + if (!refreshPromise) { + const refreshToken = currentAuth.refresh + refreshPromise = refreshAccessToken(refreshToken, options) + .then(async (tokens) => { + const refreshedExpires = Date.now() + (tokens.expires_in ?? 3600) * 1000 + const refreshedRefresh = tokens.refresh_token || refreshToken + // Persist the rotated pair as best-effort. xAI has already consumed the + // old refresh_token by the time we get here; an auth.set failure leaves + // the on-disk state stale but the in-memory result is still valid for + // this turn. The next live refresh against the stale disk state will + // 4xx and force re-login — a known cross-process limitation. + await input.client.auth + .set({ + path: { id: "xai" }, + body: { + type: "oauth", + access: tokens.access_token, + refresh: refreshedRefresh, + expires: refreshedExpires, + }, + }) + .catch(() => {}) + return { access: tokens.access_token, refresh: refreshedRefresh, expires: refreshedExpires } + }) + .finally(() => { + refreshPromise = undefined + }) + } + const refreshed = await refreshPromise + currentAuth = { ...currentAuth, ...refreshed } + } + + // Copy the caller's headers into a fresh Headers (case-insensitive) + // so we never mutate the RequestInit the AI SDK may reuse on retry. + // Headers.set overwrites case-insensitively, which kills the dummy + // bearer the AI SDK injected from apiKey in a single line. + const headers = new Headers(requestInput instanceof Request ? requestInput.headers : undefined) + if (init?.headers) { + const entries = + init.headers instanceof Headers + ? init.headers.entries() + : Array.isArray(init.headers) + ? init.headers + : Object.entries(init.headers as Record) + for (const [key, value] of entries) { + if (value !== undefined) headers.set(key, String(value)) + } + } + headers.set("authorization", `Bearer ${currentAuth.access}`) + headers.set("User-Agent", `opencode/${InstallationVersion}`) + + return fetch(requestInput, { ...init, headers }) + }, + } + }, + methods: [ + { + // RFC 8628 device-code flow. The CLI prints a verification URL + // and a short user_code that the user enters in a browser on + // any device. No loopback callback server runs on the CLI host, + // so this works on VPS / SSH / Docker / CI / WSL / any + // environment where 127.0.0.1:56121 isn't reachable from the + // user's browser. Defends the only attack surface (the polling + // loop) with the standard authorization_pending / slow_down + // backoff and a hard deadline from xAI's `expires_in`. + label: "SuperGrok Subscription", + type: "oauth", + authorize: async () => { + const device = await requestDeviceCode(options) + const browserUrl = device.verification_uri_complete ?? device.verification_uri + return { + url: browserUrl, + instructions: `Open ${device.verification_uri} on any device and enter code: ${device.user_code}`, + method: "auto" as const, + callback: async () => { + try { + const tokens = await pollDeviceCodeToken(device, options) + return { + type: "success" as const, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + } + } catch (err) { + return { type: "failed" as const } + } + }, + } + }, + }, + { + label: "Manually enter API Key", + type: "api", + }, + ], + }, + } +} diff --git a/packages/opencode/src/project/bootstrap-service.ts b/packages/opencode/src/project/bootstrap-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..b20cc54cd623321746503776bcd99b03da64a835 --- /dev/null +++ b/packages/opencode/src/project/bootstrap-service.ts @@ -0,0 +1,9 @@ +import { Context, Effect } from "effect" + +export interface Interface { + readonly run: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceBootstrap") {} + +export * as InstanceBootstrap from "./bootstrap-service" diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts new file mode 100644 index 0000000000000000000000000000000000000000..acf0caed3cdf05c7af0a8d1dbaa994a70a172c51 --- /dev/null +++ b/packages/opencode/src/project/bootstrap.ts @@ -0,0 +1,58 @@ +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" +import { Plugin } from "../plugin" +import { Format } from "../format" +import { LSP } from "@/lsp/lsp" +import { Snapshot } from "../snapshot" +import * as Project from "./project" +import * as Vcs from "./vcs" +import { InstanceState } from "@/effect/instance-state" +import { ShareNext } from "@/share/share-next" +import { Effect, Layer } from "effect" +import { Config } from "@/config/config" +import { Service } from "./bootstrap-service" + +export { Service } from "./bootstrap-service" +export type { Interface } from "./bootstrap-service" + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + // Yield each bootstrap dep at layer init so `run` itself has R = never. + // InstanceStore imports only the lightweight tag from bootstrap-service.ts, + // so it can depend on bootstrap without importing this implementation graph. + const config = yield* Config.Service + const format = yield* Format.Service + const lsp = yield* LSP.Service + const plugin = yield* Plugin.Service + const project = yield* Project.Service + const shareNext = yield* ShareNext.Service + const snapshot = yield* Snapshot.Service + const vcs = yield* Vcs.Service + + const run = Effect.gen(function* () { + const ctx = yield* InstanceState.context + yield* Effect.logInfo("bootstrapping", { directory: ctx.directory }) + // everything depends on config so eager load it for nice traces + yield* config.get() + // Plugin can mutate config so it has to be initialized before anything else. + yield* plugin.init() + // Each service self-manages its own slow work via Effect.forkScoped against + // its per-instance state scope. We just await materialization here. + yield* Effect.forEach( + [lsp, shareNext, format, vcs, snapshot, project], + (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.withSpan("InstanceBootstrap.init")) + }).pipe(Effect.withSpan("InstanceBootstrap")) + + return Service.of({ run }) + }), +) + +export const node = makeGlobalNode({ + service: Service, + layer: layer, + deps: [Config.node, Format.node, LSP.node, Plugin.node, Project.node, ShareNext.node, Snapshot.node, Vcs.node], +}) + +export * as InstanceBootstrap from "./bootstrap" diff --git a/packages/opencode/src/project/instance-context.ts b/packages/opencode/src/project/instance-context.ts new file mode 100644 index 0000000000000000000000000000000000000000..18ea39e16e9175be15489d2593df26dec4c4f526 --- /dev/null +++ b/packages/opencode/src/project/instance-context.ts @@ -0,0 +1,24 @@ +import { LocalContext } from "@/util/local-context" +import { FSUtil } from "@opencode-ai/core/fs-util" +import type * as Project from "./project" + +export interface InstanceContext { + directory: string + worktree: string + project: Project.Info +} + +export const context = LocalContext.create("instance") + +/** + * Check if a path is within the project boundary. + * Returns true if path is inside ctx.directory OR ctx.worktree. + * Paths within the worktree but outside the working directory should not trigger external_directory permission. + */ +export function containsPath(filepath: string, ctx: InstanceContext): boolean { + if (FSUtil.contains(ctx.directory, filepath)) return true + // Non-git projects set worktree to "/" which would match ANY absolute path. + // Skip worktree check in this case to preserve external_directory permissions. + if (ctx.worktree === "/") return false + return FSUtil.contains(ctx.worktree, filepath) +} diff --git a/packages/opencode/src/project/instance-runtime.ts b/packages/opencode/src/project/instance-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8803847a07fd0db4a2a6d175d1f41a31f23993f --- /dev/null +++ b/packages/opencode/src/project/instance-runtime.ts @@ -0,0 +1,16 @@ +import { AppRuntime } from "@/effect/app-runtime" +import { type InstanceContext } from "./instance-context" +import { InstanceStore, type LoadInput } from "./instance-store" + +// Bridge for Promise/ALS callers that cannot yet yield InstanceStore.Service. +// Delete this module once those callers are migrated to Effect boundaries that +// provide InstanceStore directly. + +export const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load(input))) +export const disposeInstance = (ctx: InstanceContext) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx))) +export const disposeAllInstances = () => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll())) +export const reloadInstance = (input: LoadInput) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input))) + +export * as InstanceRuntime from "./instance-runtime" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..720549ddaff7e0c8b51a2a0f7c1597935e5b119a --- /dev/null +++ b/packages/opencode/src/project/instance-store.ts @@ -0,0 +1,213 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { makeGlobalNode, Node } from "@opencode-ai/core/effect/app-node" +import { GlobalBus } from "@/bus/global" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { InstanceRef } from "@/effect/instance-ref" +import { disposeInstance as runDisposers } from "@/effect/instance-registry" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" +import { type InstanceContext } from "./instance-context" +import { InstanceBootstrap } from "./bootstrap-service" +import * as Project from "./project" + +export interface LoadInput { + directory: string + worktree?: string + project?: Project.Info +} + +export interface Interface { + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect + readonly dispose: (ctx: InstanceContext) => Effect.Effect + readonly disposeDirectory: (directory: string) => Effect.Effect + readonly disposeAll: () => Effect.Effect + readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceStore") {} + +export const use = serviceUse(Service) + +interface Entry { + readonly deferred: Deferred.Deferred +} + +const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const project = yield* Project.Service + const bootstrap = yield* InstanceBootstrap.Service + const scope = yield* Scope.Scope + const cache = new Map() + + const boot = (input: LoadInput & { directory: string }) => + Effect.gen(function* () { + const ctx: InstanceContext = + input.project && input.worktree + ? { + directory: input.directory, + worktree: input.worktree, + project: input.project, + } + : yield* project.fromDirectory(input.directory).pipe( + Effect.map((result) => ({ + directory: input.directory, + worktree: result.sandbox, + project: result.project, + })), + ) + yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx)) + return ctx + }).pipe(Effect.withSpan("InstanceStore.boot")) + + const removeEntry = (directory: string, entry: Entry) => + Effect.sync(() => { + if (cache.get(directory) !== entry) return false + cache.delete(directory) + return true + }) + + const completeLoad = (directory: string, input: LoadInput, entry: Entry) => + Effect.gen(function* () { + const exit = yield* Effect.exit(boot({ ...input, directory })) + if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) + yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid) + }) + + const emitDisposed = (input: { directory: string; project?: string }) => + Effect.sync(() => + GlobalBus.emit("event", { + directory: input.directory, + project: input.project, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory: input.directory, + }, + }, + }), + ) + + const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) { + yield* Effect.logInfo("disposing instance", { directory: ctx.directory }) + yield* Effect.promise(() => runDisposers(ctx.directory)) + yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id }) + }) + + const disposeEntry = Effect.fnUntraced(function* (directory: string, entry: Entry, ctx: InstanceContext) { + if (cache.get(directory) !== entry) return false + yield* disposeContext(ctx) + if (cache.get(directory) !== entry) return false + cache.delete(directory) + return true + }) + + const load = (input: LoadInput): Effect.Effect => { + const directory = FSUtil.resolve(input.directory) + return Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const existing = cache.get(directory) + if (existing) return yield* restore(Deferred.await(existing.deferred)) + + const entry: Entry = { deferred: Deferred.makeUnsafe() } + cache.set(directory, entry) + yield* Effect.gen(function* () { + yield* Effect.logInfo("creating instance", { directory: directory }) + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ).pipe(Effect.withSpan("InstanceStore.load")) + } + + const reload = (input: LoadInput): Effect.Effect => { + const directory = FSUtil.resolve(input.directory) + return Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const previous = cache.get(directory) + const entry: Entry = { deferred: Deferred.makeUnsafe() } + cache.set(directory, entry) + yield* Effect.gen(function* () { + yield* Effect.logInfo("reloading instance", { directory: directory }) + if (previous) { + yield* Deferred.await(previous.deferred).pipe(Effect.ignore) + yield* Effect.promise(() => runDisposers(directory)) + yield* emitDisposed({ directory, project: input.project?.id }) + } + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ).pipe(Effect.withSpan("InstanceStore.reload")) + } + + const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) { + const entry = cache.get(ctx.directory) + if (!entry) return yield* disposeContext(ctx) + + const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) return yield* removeEntry(ctx.directory, entry).pipe(Effect.asVoid) + if (exit.value !== ctx) return + yield* disposeEntry(ctx.directory, entry, ctx).pipe(Effect.asVoid) + }) + + const disposeDirectory = Effect.fn("InstanceStore.disposeDirectory")(function* (input: string) { + const directory = FSUtil.resolve(input) + const entry = cache.get(directory) + if (!entry) return + const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) return yield* removeEntry(directory, entry).pipe(Effect.asVoid) + yield* disposeEntry(directory, entry, exit.value).pipe(Effect.asVoid) + }) + + const disposeAllOnce = Effect.fnUntraced(function* () { + yield* Effect.logInfo("disposing all instances") + yield* Effect.forEach( + [...cache.entries()], + (item) => + Effect.gen(function* () { + const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause }) + yield* removeEntry(item[0], item[1]) + return + } + yield* disposeEntry(item[0], item[1], exit.value) + }), + { discard: true }, + ) + }) + + const cachedDisposeAll = yield* Effect.cachedWithTTL(disposeAllOnce(), Duration.zero) + const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () { + return yield* cachedDisposeAll + }) + + const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => + load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) + + yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) + + return Service.of({ + load, + reload, + dispose, + disposeDirectory, + disposeAll, + provide, + }) + }), +) + +export const bootstrapNode = LayerNode.unbound(InstanceBootstrap.Service, Node.tags.values.global) + +export const node = makeGlobalNode({ + service: Service, + layer: layer, + deps: [Project.node, bootstrapNode], +}) + +export * as InstanceStore from "./instance-store" diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts new file mode 100644 index 0000000000000000000000000000000000000000..9870377b2169d7ebfdfa4f7886b2d7c245729e67 --- /dev/null +++ b/packages/opencode/src/project/project.ts @@ -0,0 +1,483 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { and, eq, sql } from "drizzle-orm" +import { Database } from "@opencode-ai/core/database/database" +import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql" +import { ProjectDirectories } from "@opencode-ai/core/project/directories" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" +import { Flag } from "@opencode-ai/core/flag/flag" +import { GlobalBus } from "@/bus/global" +import { which } from "@opencode-ai/core/util/which" +import { Command } from "@/command" +import { InstanceState } from "@/effect/instance-state" +import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppProcess } from "@opencode-ai/core/process" +import { ProjectV2 } from "@opencode-ai/core/project" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/schema/project" + +export const Info = Project.Info +export type Info = Types.DeepMutable> + +export const Event = { + Updated: Project.Event.Updated, +} + +type Row = typeof ProjectTable.$inferSelect + +export function fromRow(row: Row): Info { + const icon = + row.icon_url || row.icon_url_override || row.icon_color + ? { + url: row.icon_url ?? undefined, + override: row.icon_url_override ?? undefined, + color: row.icon_color ?? undefined, + } + : undefined + return { + id: row.id, + worktree: row.worktree, + vcs: row.vcs ? Schema.decodeUnknownSync(Project.Vcs)(row.vcs) : undefined, + name: row.name ?? undefined, + icon, + time: { + created: row.time_created, + updated: row.time_updated, + initialized: row.time_initialized ?? undefined, + }, + sandboxes: row.sandboxes, + commands: row.commands ?? undefined, + } +} + +export const UpdateInput = Schema.Struct({ + projectID: ProjectV2.ID, + name: Schema.optional(Schema.String), + icon: Schema.optional(Project.Icon), + commands: Schema.optional(Project.Commands), +}) +export type UpdateInput = Types.DeepMutable> + +export const UpdatePayload = Schema.Struct({ + name: Schema.optional(Schema.String), + icon: Schema.optional(Project.Icon), + commands: Schema.optional(Project.Commands), +}).annotate({ identifier: "ProjectUpdateInput" }) +export type UpdatePayload = Types.DeepMutable> + +export class NotFoundError extends Schema.TaggedErrorClass()("Project.NotFoundError", { + projectID: ProjectV2.ID, +}) {} + +// --------------------------------------------------------------------------- +// Effect service +// --------------------------------------------------------------------------- + +export interface Interface { + /** + * Per-instance setup. Subscribes to the `/init` slash command for the + * current instance and stamps the project's initialized timestamp when it + * fires. Subscription lifetime is tied to the per-instance state scope. + */ + readonly init: () => Effect.Effect + readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }> + readonly discover: (input: Info) => Effect.Effect + readonly list: () => Effect.Effect + readonly get: (id: ProjectV2.ID) => Effect.Effect + readonly update: (input: UpdateInput) => Effect.Effect + readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect + readonly setInitialized: (id: ProjectV2.ID) => Effect.Effect + readonly sandboxes: (id: ProjectV2.ID) => Effect.Effect + readonly addSandbox: (id: ProjectV2.ID, directory: string) => Effect.Effect + readonly removeSandbox: (id: ProjectV2.ID, directory: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Project") {} + +type GitResult = { code: number; text: string; stderr: string } + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const projectV2 = yield* ProjectV2.Service + const projectDirectories = yield* ProjectDirectories.Service + const events = yield* EventV2Bridge.Service + const flags = yield* RuntimeFlags.Service + const { db } = yield* Database.Service + + const git = Effect.fnUntraced( + function* (args: string[], opts?: { cwd?: string }) { + const handle = yield* spawner.spawn( + ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }), + ) + const [text, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, text, stderr } satisfies GitResult + }, + Effect.scoped, + Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)), + ) + + const emitUpdated = (data: Info) => + Effect.sync(() => + GlobalBus.emit("event", { + directory: "global", + project: data.id, + payload: { type: Event.Updated.type, properties: data }, + }), + ) + + const fakeVcs = Schema.decodeUnknownSync(Schema.optional(Project.Vcs))(Flag.OPENCODE_FAKE_VCS) + + const scope = yield* Scope.Scope + + const migrateProjectId = Effect.fn("Project.migrateProjectId")(function* ( + oldID: ProjectV2.ID | undefined, + newID: ProjectV2.ID, + ) { + if (!oldID) return + if (oldID === ProjectV2.ID.global) return + if (oldID === newID) return + + yield* db + .transaction( + (d) => + Effect.gen(function* () { + const oldProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get() + const newProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get() + if (oldProject && !newProject) { + yield* d + .insert(ProjectTable) + .values({ + ...oldProject, + id: newID, + time_updated: Date.now(), + }) + .run() + } + + // Project directories may be shared across distinct + // checkouts which have diverged. Clear the directory + // list and rely on it being re-populated to ensure + // accuracy + yield* d.delete(ProjectDirectoryTable).where(eq(ProjectDirectoryTable.project_id, oldID)).run() + + yield* d + .update(SessionTable) + .set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` }) + .where(eq(SessionTable.project_id, oldID)) + .run() + yield* d + .update(WorkspaceTable) + .set({ project_id: newID }) + .where(eq(WorkspaceTable.project_id, oldID)) + .run() + + if (oldProject) yield* d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run() + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + }) + + const saveProjectDirectory = Effect.fn("Project.saveProjectDirectory")(function* (input: { + projectID: ProjectV2.ID + directory: string + }) { + if (input.projectID === ProjectV2.ID.global) return + const opened = AbsolutePath.make(FSUtil.resolve(input.directory)) + yield* projectDirectories + .create({ + directory: opened, + projectID: input.projectID, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("project directory persistence failed", { projectID: input.projectID, cause }), + ), + ) + }) + + const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) { + yield* Effect.logInfo("fromDirectory", { directory }) + + const data = yield* projectV2.resolve(AbsolutePath.make(directory)) + const worktree = data.id === ProjectV2.ID.make("global") && !data.vcs ? "/" : data.directory + + // Phase 2: upsert + const projectID = ProjectV2.ID.make(data.id) + yield* migrateProjectId(data.previous ? ProjectV2.ID.make(data.previous) : undefined, projectID) + const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie) + const existing = row + ? fromRow(row) + : { + id: projectID, + worktree, + vcs: data.vcs?.type ?? fakeVcs, + sandboxes: [] as string[], + time: { created: Date.now(), updated: Date.now() }, + } + + if (flags.experimentalIconDiscovery) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope)) + + const result: Info = { + ...existing, + worktree: projectID === ProjectV2.ID.global ? worktree : existing.worktree, + vcs: data.vcs?.type ?? fakeVcs, + time: { ...existing.time, updated: Date.now() }, + } + if ( + projectID !== ProjectV2.ID.global && + data.directory !== result.worktree && + !result.sandboxes.includes(data.directory) + ) + result.sandboxes.push(data.directory) + result.sandboxes = yield* Effect.forEach( + result.sandboxes, + (s) => + fs.exists(s).pipe( + Effect.orDie, + Effect.map((exists) => (exists ? s : undefined)), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) + + yield* db + .insert(ProjectTable) + .values({ + id: result.id, + worktree: AbsolutePath.make(result.worktree), + vcs: result.vcs ?? null, + name: result.name, + icon_url: result.icon?.url, + icon_url_override: result.icon?.override, + icon_color: result.icon?.color, + time_created: result.time.created, + time_updated: result.time.updated, + time_initialized: result.time.initialized, + sandboxes: result.sandboxes.map((sandbox) => AbsolutePath.make(sandbox)), + commands: result.commands, + }) + .onConflictDoUpdate({ + target: ProjectTable.id, + set: { + worktree: AbsolutePath.make(result.worktree), + vcs: result.vcs ?? null, + name: result.name, + icon_url: result.icon?.url, + icon_url_override: result.icon?.override, + icon_color: result.icon?.color, + time_updated: result.time.updated, + time_initialized: result.time.initialized, + sandboxes: result.sandboxes.map((sandbox) => AbsolutePath.make(sandbox)), + commands: result.commands, + }, + }) + .run() + .pipe(Effect.orDie) + + if (projectID !== ProjectV2.ID.global) { + yield* db + .update(SessionTable) + .set({ project_id: projectID }) + .where(and(eq(SessionTable.project_id, ProjectV2.ID.global), eq(SessionTable.directory, data.directory))) + .run() + .pipe(Effect.orDie) + } + + yield* saveProjectDirectory({ + projectID, + directory: data.directory, + }) + + yield* emitUpdated(result) + if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") { + yield* projectV2.commit({ store: data.vcs.store, id: data.id }) + } + return { project: result, sandbox: data.vcs ? data.directory : worktree } + }) + + const discover = Effect.fn("Project.discover")(function* (input: Info) { + if (input.vcs !== "git") return + if (input.icon?.override) return + if (input.icon?.url) return + + const matches = yield* fs + .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", { + cwd: input.worktree, + absolute: true, + include: "file", + }) + .pipe(Effect.orDie) + const shortest = matches.sort((a, b) => a.length - b.length)[0] + if (!shortest) return + + const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie) + const base64 = Buffer.from(buffer).toString("base64") + const mime = FSUtil.mimeType(shortest) + const url = `data:${mime};base64,${base64}` + yield* update({ projectID: input.id, icon: { url } }).pipe( + Effect.catchTag("Project.NotFoundError", () => Effect.void), + ) + }) + + const list = Effect.fn("Project.list")(function* () { + return (yield* db.select().from(ProjectTable).all().pipe(Effect.orDie)).map(fromRow) + }) + + const get = Effect.fn("Project.get")(function* (id: ProjectV2.ID) { + const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie) + return row ? fromRow(row) : undefined + }) + + const update = Effect.fn("Project.update")(function* (input: UpdateInput) { + const result = yield* db + .update(ProjectTable) + .set({ + name: input.name, + icon_url: input.icon?.url, + icon_url_override: input.icon?.override, + icon_color: input.icon?.color, + commands: input.commands, + time_updated: Date.now(), + }) + .where(eq(ProjectTable.id, input.projectID)) + .returning() + .get() + .pipe(Effect.orDie) + if (!result) return yield* new NotFoundError({ projectID: input.projectID }) + const data = fromRow(result) + yield* emitUpdated(data) + return data + }) + + const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) { + if (input.project.vcs === "git") return input.project + if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed") + const result = yield* git(["init", "--quiet"], { cwd: input.directory }) + if (result.code !== 0) { + throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository") + } + const { project } = yield* fromDirectory(input.directory) + return project + }) + + const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectV2.ID) { + yield* db + .update(ProjectTable) + .set({ time_initialized: Date.now() }) + .where(eq(ProjectTable.id, id)) + .run() + .pipe(Effect.orDie) + }) + + const initState = yield* InstanceState.make( + Effect.fn("Project.initState")(function* (ctx) { + const unsubscribe = yield* events.listen((event) => { + if (event.type !== Command.Event.Executed.type || event.location?.directory !== ctx.directory) + return Effect.void + const data = event.data as EventV2.Data + return data.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void + }) + yield* Effect.addFinalizer(() => unsubscribe) + }), + ) + + const init = Effect.fn("Project.init")(function* () { + yield* InstanceState.get(initState) + }) + + const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectV2.ID) { + const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie) + if (!row) return [] + const data = fromRow(row) + return yield* Effect.forEach( + data.sandboxes, + (dir) => + fs.isDir(dir).pipe( + Effect.orDie, + Effect.map((ok) => (ok ? dir : undefined)), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) + }) + + const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectV2.ID, directory: string) { + const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie) + if (!row) throw new Error(`Project not found: ${id}`) + const sandbox = AbsolutePath.make(directory) + const sboxes = [...row.sandboxes] + if (!sboxes.includes(sandbox)) sboxes.push(sandbox) + const result = yield* db + .update(ProjectTable) + .set({ sandboxes: sboxes, time_updated: Date.now() }) + .where(eq(ProjectTable.id, id)) + .returning() + .get() + .pipe(Effect.orDie) + if (!result) throw new Error(`Project not found: ${id}`) + yield* emitUpdated(fromRow(result)) + }) + + const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectV2.ID, directory: string) { + const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie) + if (!row) throw new Error(`Project not found: ${id}`) + const sandbox = AbsolutePath.make(directory) + const sboxes = row.sandboxes.filter((s) => s !== sandbox) + const result = yield* db + .update(ProjectTable) + .set({ sandboxes: sboxes, time_updated: Date.now() }) + .where(eq(ProjectTable.id, id)) + .returning() + .get() + .pipe(Effect.orDie) + if (!result) throw new Error(`Project not found: ${id}`) + yield* emitUpdated(fromRow(result)) + }) + + return Service.of({ + init, + fromDirectory, + discover, + list, + get, + update, + initGit, + setInitialized, + sandboxes, + addSandbox, + removeSandbox, + }) + }), +) + +export const use = serviceUse(Service) + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [ + FSUtil.node, + AppProcess.node, + CrossSpawnSpawner.node, + ProjectV2.node, + ProjectDirectories.node, + EventV2Bridge.node, + RuntimeFlags.node, + Database.node, + ], +}) + +export * as Project from "./project" diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts new file mode 100644 index 0000000000000000000000000000000000000000..eca56c0501a16180af6a71ea68a2841c85606769 --- /dev/null +++ b/packages/opencode/src/project/vcs.ts @@ -0,0 +1,423 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer, Context, Schema, Scope } from "effect" +import { formatPatch, structuredPatch } from "diff" +import { InstanceState } from "@/effect/instance-state" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { Git } from "@/git" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" +import { VcsEvent } from "@opencode-ai/schema/vcs-event" + +const PATCH_CONTEXT_LINES = 2_147_483_647 +const MAX_PATCH_BYTES = 10_000_000 +const MAX_TOTAL_PATCH_BYTES = 10_000_000 +type DiffOptions = { + readonly context?: number +} + +const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 })) + +const nums = (list: Git.Stat[]) => + new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const)) + +const merge = (...lists: Git.Item[][]) => { + const out = new Map() + lists.flat().forEach((item) => { + if (!out.has(item.file)) out.set(item.file, item) + }) + return [...out.values()] +} + +const emptyBatch = () => ({ patches: new Map(), capped: false }) + +const parseQuotedPath = (value: string) => { + let out = "" + for (let idx = 1; idx < value.length; idx++) { + const char = value[idx] + if (char === '"') return { value: out, end: idx + 1 } + if (char !== "\\") { + out += char + continue + } + + const next = value[++idx] + if (next === "t") out += "\t" + else if (next === "n") out += "\n" + else if (next === "r") out += "\r" + else if (next === '"' || next === "\\") out += next + else out += next ?? "" + } +} + +const parsePathToken = (value: string) => { + if (!value.startsWith('"')) return value.split("\t")[0] + return parseQuotedPath(value)?.value ?? value +} + +const fileFromDiffPath = (value: string | undefined) => { + if (!value || value === "/dev/null") return + const file = parsePathToken(value) + if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2) + return file +} + +const fileFromGitHeader = (header: string) => { + if (header.startsWith('"')) { + const first = parseQuotedPath(header) + const second = first ? header.slice(first.end).trimStart() : undefined + if (!second) return + if (!second.startsWith('"')) return fileFromDiffPath(second) + return fileFromDiffPath(parseQuotedPath(second)?.value) + } + + const separator = header.indexOf(" b/") + if (separator === -1) return + return fileFromDiffPath(header.slice(separator + 1)) +} + +const fileFromPatchChunk = (chunk: string) => { + const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1] + const before = /^--- (.+)$/m.exec(chunk)?.[1] + const file = fileFromDiffPath(next) ?? fileFromDiffPath(before) + if (file) return file + + const header = /^diff --git (.+)$/m.exec(chunk)?.[1] + return fileFromGitHeader(header ?? "") +} + +const splitGitPatch = (patch: Git.Patch) => { + const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) => + match[0].startsWith("\n") ? match.index + 1 : match.index, + ) + const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length)) + if (!patch.truncated) return chunks + return chunks.slice(0, -1) +} + +const batchPatches = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string, + list: Git.Item[], + options?: DiffOptions, +) { + if (list.length === 0) return { patches: new Map(), capped: false } + + const result = yield* git.patchAll(cwd, ref, { + context: options?.context ?? PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_TOTAL_PATCH_BYTES, + }) + + return { + patches: splitGitPatch(result).reduce((acc, patch, index) => { + const file = fileFromPatchChunk(patch) ?? list[index]?.file + if (!file) return acc + acc.set(file, (acc.get(file) ?? "") + patch) + return acc + }, new Map()), + capped: result.truncated, + } +}) + +const nativePatch = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string | undefined, + item: Git.Item, + options?: DiffOptions, +) { + const result = + item.code === "??" || !ref + ? yield* git.patchUntracked(cwd, item.file, { + context: options?.context ?? PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_PATCH_BYTES, + }) + : yield* git.patch(cwd, ref, item.file, { + context: options?.context ?? PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_PATCH_BYTES, + }) + if (!result.truncated && result.text) return result.text + + return emptyPatch(item.file) +}) + +const totalPatch = (file: string, patch: string, total: number) => { + if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false } + return { patch: emptyPatch(file), capped: true } +} + +const patchForItem = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string | undefined, + item: Git.Item, + batch: { patches: Map; capped: boolean }, + capped: boolean, + options?: DiffOptions, +) { + if (capped) return emptyPatch(item.file) + + const batched = batch.patches.get(item.file) + if (batched !== undefined) return batched + if (item.code !== "??" && batch.capped) return emptyPatch(item.file) + return yield* nativePatch(git, cwd, ref, item, options) +}) + +const files = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string | undefined, + list: Git.Item[], + map: Map, + batch: { patches: Map; capped: boolean }, + options?: DiffOptions, +) { + const next: FileDiff[] = [] + let total = 0 + let capped = false + + for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) { + const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined) + const patch = yield* patchForItem(git, cwd, ref, item, batch, capped, options) + const result: { patch: string; capped: boolean } = capped + ? { patch, capped: true } + : totalPatch(item.file, patch, total) + capped = capped || result.capped + if (!capped) { + total += Buffer.byteLength(result.patch) + capped = total >= MAX_TOTAL_PATCH_BYTES + } + next.push({ + file: item.file, + patch: result.patch, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + status: item.status, + }) + } + + return next +}) + +const diffAgainstRef = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string, + options?: DiffOptions, +) { + const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { + concurrency: 3, + }) + return yield* files( + git, + cwd, + ref, + merge( + list, + extra.filter((item) => item.code === "??"), + ), + nums(stats), + yield* batchPatches(git, cwd, ref, list, options), + options, + ) +}) + +const track = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string | undefined, + options?: DiffOptions, +) { + if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch(), options) + return yield* diffAgainstRef(git, cwd, ref, options) +}) + +export const Mode = Schema.Literals(["git", "branch"]) +export type Mode = Schema.Schema.Type + +export const Event = VcsEvent + +export const Info = Schema.Struct({ + branch: Schema.optional(Schema.String), + default_branch: Schema.optional(Schema.String), +}).annotate({ identifier: "VcsInfo" }) +export type Info = Schema.Schema.Type + +export const FileDiff = Schema.Struct({ + file: Schema.String, + // Mirrors Snapshot.FileDiff (see #26574). The current producer always + // populates patch, but loosening matches the sibling schema so a + // future code path that omits it can't crash /instance/vcs/diff. + patch: Schema.optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, + status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), +}).annotate({ identifier: "VcsFileDiff" }) +export type FileDiff = Schema.Schema.Type + +export const FileStatus = Schema.Struct({ + file: Schema.String, + additions: Schema.Finite, + deletions: Schema.Finite, + status: Schema.Literals(["added", "deleted", "modified"]), +}).annotate({ identifier: "VcsFileStatus" }) +export type FileStatus = Schema.Schema.Type + +export const ApplyInput = Schema.Struct({ + patch: Schema.String, +}) +export type ApplyInput = Schema.Schema.Type + +export const ApplyResult = Schema.Struct({ + applied: Schema.Boolean, +}) +export type ApplyResult = Schema.Schema.Type + +export class PatchApplyError extends Schema.TaggedErrorClass()("VcsPatchApplyError", { + message: Schema.String, + reason: Schema.Literals(["non-git", "not-clean"]), +}) {} + +export interface Interface { + readonly init: () => Effect.Effect + readonly branch: () => Effect.Effect + readonly defaultBranch: () => Effect.Effect + readonly status: () => Effect.Effect + readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect + readonly diffRaw: () => Effect.Effect + readonly apply: (input: ApplyInput) => Effect.Effect +} + +interface State { + current: string | undefined + root: Git.Base | undefined +} + +export class Service extends Context.Service()("@opencode/Vcs") {} + +const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const git = yield* Git.Service + const events = yield* EventV2Bridge.Service + const scope = yield* Scope.Scope + + const state = yield* InstanceState.make( + Effect.fn("Vcs.state")(function* (ctx) { + if (ctx.project.vcs !== "git") { + return { current: undefined, root: undefined } + } + + const get = Effect.fnUntraced(function* () { + return yield* git.branch(ctx.directory) + }) + const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { + concurrency: 2, + }) + const value = { current, root } + + const unsubscribe = yield* events.listen((event) => { + if (event.type !== Watcher.Event.Updated.type || event.location?.directory !== ctx.directory) + return Effect.void + const data = event.data as EventV2.Data + if (!data.file.endsWith("HEAD")) return Effect.void + return Effect.gen(function* () { + const next = yield* get() + if (next !== value.current) { + value.current = next + yield* events.publish(Event.BranchUpdated, { branch: next }) + } + }) + }) + yield* Effect.addFinalizer(() => unsubscribe) + + return value + }), + ) + + return Service.of({ + init: Effect.fn("Vcs.init")(function* () { + yield* InstanceState.get(state).pipe(Effect.forkIn(scope)) + }), + branch: Effect.fn("Vcs.branch")(function* () { + return yield* InstanceState.use(state, (x) => x.current) + }), + defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () { + return yield* InstanceState.use(state, (x) => x.root?.name) + }), + status: Effect.fn("Vcs.status")(function* () { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return [] + const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined + const [list, stats] = yield* Effect.all( + [git.status(ctx.directory), ref ? git.stats(ctx.directory, ref) : Effect.succeed([])], + { concurrency: 2 }, + ) + const map = nums(stats) + return yield* Effect.forEach( + list.toSorted((a, b) => a.file.localeCompare(b.file)), + (item) => + Effect.gen(function* () { + const stat = + map.get(item.file) ?? + (item.status === "added" ? yield* git.statUntracked(ctx.worktree, item.file) : undefined) + return { + file: item.file, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + status: item.status, + } satisfies FileStatus + }), + ) + }), + diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) { + const value = yield* InstanceState.get(state) + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return [] + if (mode === "git") { + return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined, options) + } + + if (!value.root) return [] + if (value.current && value.current === value.root.name) return [] + const ref = yield* git.mergeBase(ctx.directory, value.root.ref) + if (!ref) return [] + return yield* diffAgainstRef(git, ctx.directory, ref, options) + }), + diffRaw: Effect.fn("Vcs.diffRaw")(function* () { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return "" + const [hasHead, status] = yield* Effect.all([git.hasHead(ctx.directory), git.status(ctx.directory)], { + concurrency: 2, + }) + const tracked = hasHead ? (yield* git.patchAll(ctx.directory, "HEAD")).text : "" + const untracked = yield* Effect.forEach( + status.filter((item) => item.code === "??"), + (item) => git.patchUntracked(ctx.directory, item.file).pipe(Effect.map((patch) => patch.text)), + ) + return [tracked, ...untracked].filter(Boolean).join("\n") + }), + apply: Effect.fn("Vcs.apply")(function* (input: ApplyInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { + return yield* new PatchApplyError({ + message: "Patch can't be applied because the project is not git-based", + reason: "non-git", + }) + } + const applied = yield* git.applyPatch(ctx.directory, input.patch) + if (applied.exitCode !== 0) { + return yield* new PatchApplyError({ + message: "Patch can't be applied", + reason: "not-clean", + }) + } + return { applied: true } + }), + }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Git.node, EventV2Bridge.node] }) + +export * as Vcs from "./vcs" diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..416a3ea7b0dbeafd819b8296287be8a79f049677 --- /dev/null +++ b/packages/opencode/src/provider/auth.ts @@ -0,0 +1,229 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { Auth } from "@/auth" +import { InstanceState } from "@/effect/instance-state" +import { optional } from "@opencode-ai/core/schema" +import { Plugin } from "../plugin" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" + +const When = Schema.Struct({ + key: Schema.String, + op: Schema.Literals(["eq", "neq"]), + value: Schema.String, +}) + +const TextPrompt = Schema.Struct({ + type: Schema.Literal("text"), + key: Schema.String, + message: Schema.String, + placeholder: optional(Schema.String), + when: optional(When), +}) + +const SelectOption = Schema.Struct({ + label: Schema.String, + value: Schema.String, + hint: optional(Schema.String), +}) + +const SelectPrompt = Schema.Struct({ + type: Schema.Literal("select"), + key: Schema.String, + message: Schema.String, + options: Schema.Array(SelectOption), + when: optional(When), +}) + +const Prompt = Schema.Union([TextPrompt, SelectPrompt]) + +export class Method extends Schema.Class("ProviderAuthMethod")({ + type: Schema.Literals(["oauth", "api"]), + label: Schema.String, + prompts: optional(Schema.Array(Prompt)), +}) {} + +export const Methods = Schema.Record(Schema.String, Schema.Array(Method)) +export type Methods = typeof Methods.Type + +export class Authorization extends Schema.Class("ProviderAuthAuthorization")({ + url: Schema.String, + method: Schema.Literals(["auto", "code"]), + instructions: Schema.String, +}) {} + +export const AuthorizeInput = Schema.Struct({ + method: Schema.Finite.annotate({ description: "Auth method index" }), + inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }), +}) +export type AuthorizeInput = Schema.Schema.Type + +export const CallbackInput = Schema.Struct({ + method: Schema.Finite.annotate({ description: "Auth method index" }), + code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }), +}) +export type CallbackInput = Schema.Schema.Type + +export class OauthMissing extends Schema.TaggedErrorClass()("ProviderAuthOauthMissing", { + providerID: ProviderV2.ID, +}) {} + +export class OauthCodeMissing extends Schema.TaggedErrorClass()("ProviderAuthOauthCodeMissing", { + providerID: ProviderV2.ID, +}) {} + +export class OauthCallbackFailed extends Schema.TaggedErrorClass()( + "ProviderAuthOauthCallbackFailed", + {}, +) {} + +export class ValidationFailed extends Schema.TaggedErrorClass()("ProviderAuthValidationFailed", { + field: Schema.String, + message: Schema.String, +}) {} + +export type Error = Auth.AuthError | OauthMissing | OauthCodeMissing | OauthCallbackFailed | ValidationFailed + +type Hook = NonNullable + +export interface Interface { + readonly methods: () => Effect.Effect + readonly authorize: ( + input: { + providerID: ProviderV2.ID + } & AuthorizeInput, + ) => Effect.Effect + readonly callback: (input: { providerID: ProviderV2.ID } & CallbackInput) => Effect.Effect +} + +interface State { + hooks: Record + pending: Map +} + +export class Service extends Context.Service()("@opencode/ProviderAuth") {} + +export const use = serviceUse(Service) + +const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.Service + const plugin = yield* Plugin.Service + const state = yield* InstanceState.make( + Effect.fn("ProviderAuth.state")(function* () { + const plugins = yield* plugin.list() + return { + hooks: Record.fromEntries( + Arr.filterMap(plugins, (x) => + x.auth?.provider !== undefined + ? Result.succeed([ProviderV2.ID.make(x.auth.provider), x.auth] as const) + : Result.failVoid, + ), + ), + pending: new Map(), + } + }), + ) + + const decode = Schema.decodeUnknownSync(Methods) + const methods = Effect.fn("ProviderAuth.methods")(function* () { + const hooks = (yield* InstanceState.get(state)).hooks + return decode( + Record.map(hooks, (item) => + item.methods.map((method) => ({ + type: method.type, + label: method.label, + ...(method.prompts && { + prompts: method.prompts.map((prompt) => { + if (prompt.type === "select") { + return { + type: "select" as const, + key: prompt.key, + message: prompt.message, + options: prompt.options, + ...(prompt.when && { when: prompt.when }), + } + } + return { + type: "text" as const, + key: prompt.key, + message: prompt.message, + ...(prompt.placeholder && { placeholder: prompt.placeholder }), + ...(prompt.when && { when: prompt.when }), + } + }), + }), + })), + ), + ) + }) + + const authorize = Effect.fn("ProviderAuth.authorize")(function* ( + input: { providerID: ProviderV2.ID } & AuthorizeInput, + ) { + const { hooks, pending } = yield* InstanceState.get(state) + const method = hooks[input.providerID].methods[input.method] + if (method.type !== "oauth") return + + if (method.prompts && input.inputs) { + for (const prompt of method.prompts) { + if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) { + const error = prompt.validate(input.inputs[prompt.key]) + if (error) return yield* new ValidationFailed({ field: prompt.key, message: error }) + } + } + } + + const result = yield* Effect.promise(() => method.authorize(input.inputs)) + pending.set(input.providerID, result) + return { + url: result.url, + method: result.method, + instructions: result.instructions, + } + }) + + const callback = Effect.fn("ProviderAuth.callback")(function* ( + input: { providerID: ProviderV2.ID } & CallbackInput, + ) { + const pending = (yield* InstanceState.get(state)).pending + const match = pending.get(input.providerID) + if (!match) return yield* new OauthMissing({ providerID: input.providerID }) + if (match.method === "code" && !input.code) { + return yield* new OauthCodeMissing({ providerID: input.providerID }) + } + + const result = yield* Effect.promise(() => + match.method === "code" ? match.callback(input.code!) : match.callback(), + ) + if (!result || result.type !== "success") return yield* new OauthCallbackFailed({}) + + if ("key" in result) { + yield* auth.set(input.providerID, { + type: "api", + key: result.key, + ...(result.metadata ? { metadata: result.metadata } : {}), + }) + } + + if ("refresh" in result) { + const { type: _, provider: __, refresh, access, expires, ...extra } = result + yield* auth.set(input.providerID, { + type: "oauth", + access, + refresh, + expires, + ...extra, + }) + } + }) + + return Service.of({ methods, authorize, callback }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Auth.node, Plugin.node] }) + +export * as ProviderAuth from "./auth" diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts new file mode 100644 index 0000000000000000000000000000000000000000..cebd08145d210d008b8a09a797b176061c1f0be7 --- /dev/null +++ b/packages/opencode/src/provider/error.ts @@ -0,0 +1,195 @@ +import { APICallError } from "ai" +import { STATUS_CODES } from "http" +import { iife } from "@/util/iife" +import type { ProviderV2 } from "@opencode-ai/core/provider" +import { isContextOverflow } from "@opencode-ai/llm" + +export class HeaderTimeoutError extends Error { + public override readonly name = "ProviderHeaderTimeoutError" + + constructor(public readonly ms: number) { + super(`Provider response headers timed out after ${ms}ms`) + } +} + +export class ResponseStreamError extends Error { + public override readonly name = "ProviderResponseStreamError" + + constructor(message: string, options?: ErrorOptions) { + super(message, options) + } +} + +function isOpenAiErrorRetryable(e: APICallError) { + const status = e.statusCode + if (!status) return e.isRetryable + // openai sometimes returns 404 for models that are actually available + return status === 404 || e.isRetryable +} + +// Providers not reliably handled in this function: +// - z.ai: can accept overflow silently (needs token-count/context-window checks) +function message(providerID: ProviderV2.ID, e: APICallError) { + return iife(() => { + const msg = e.message + if (msg === "") { + if (e.responseBody) return e.responseBody + if (e.statusCode) { + const err = STATUS_CODES[e.statusCode] + if (err) return err + } + return "Unknown error" + } + + if (!e.responseBody || (e.statusCode && msg !== STATUS_CODES[e.statusCode])) { + return msg + } + + try { + const body = JSON.parse(e.responseBody) + // try to extract common error message fields + const errMsg = body.message || body.error || body.error?.message + if (errMsg && typeof errMsg === "string") { + return `${msg}: ${errMsg}` + } + } catch {} + + // If responseBody is HTML (e.g. from a gateway or proxy error page), + // provide a human-readable message instead of dumping raw markup + if (/^\s*` to re-authenticate." + } + if (e.statusCode === 403) { + return "Forbidden: request was blocked by a gateway or proxy. You may not have permission to access this resource — check your account and provider settings." + } + return msg + } + + return `${msg}: ${e.responseBody}` + }).trim() +} + +function json(input: unknown) { + if (typeof input === "string") { + try { + const result = JSON.parse(input) + if (result && typeof result === "object") return result + return undefined + } catch { + return undefined + } + } + if (typeof input === "object" && input !== null) { + return input + } + return undefined +} + +export type ParsedStreamError = + | { + type: "context_overflow" + message: string + responseBody: string + } + | { + type: "api_error" + message: string + isRetryable: boolean + responseBody: string + } + +export function parseStreamError(input: unknown): ParsedStreamError | undefined { + const raw = json(input) + const body = typeof raw?.message === "string" ? (json(raw.message) ?? raw) : raw + if (!body) return + + const responseBody = JSON.stringify(body) + if (body.type !== "error") return + + switch (body?.error?.code) { + case "context_length_exceeded": + return { + type: "context_overflow", + message: "Input exceeds context window of this model", + responseBody, + } + case "insufficient_quota": + return { + type: "api_error", + message: "Quota exceeded. Check your plan and billing details.", + isRetryable: false, + responseBody, + } + case "usage_not_included": + return { + type: "api_error", + message: "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus.", + isRetryable: false, + responseBody, + } + case "invalid_prompt": + return { + type: "api_error", + message: typeof body?.error?.message === "string" ? body?.error?.message : "Invalid prompt.", + isRetryable: false, + responseBody, + } + case "server_is_overloaded": + case "server_error": + return { + type: "api_error", + message: typeof body?.error?.message === "string" ? body?.error?.message : "Server error.", + isRetryable: true, + responseBody, + } + } + + return { + type: "api_error", + message: typeof body?.error?.message === "string" ? body.error.message : "Server error.", + isRetryable: true, + responseBody, + } +} + +export type ParsedAPICallError = + | { + type: "context_overflow" + message: string + responseBody?: string + } + | { + type: "api_error" + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: Record + responseBody?: string + metadata?: Record + } + +export function parseAPICallError(input: { providerID: ProviderV2.ID; error: APICallError }): ParsedAPICallError { + const m = message(input.providerID, input.error) + const body = json(input.error.responseBody) + if (isContextOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") { + return { + type: "context_overflow", + message: m, + responseBody: input.error.responseBody, + } + } + + const metadata = input.error.url ? { url: input.error.url } : undefined + return { + type: "api_error", + message: m, + statusCode: input.error.statusCode, + isRetryable: input.providerID.startsWith("openai") ? isOpenAiErrorRetryable(input.error) : input.error.isRetryable, + responseHeaders: input.error.responseHeaders, + responseBody: input.error.responseBody, + metadata, + } +} + +export * as ProviderError from "./error" diff --git a/packages/opencode/src/provider/model-status.ts b/packages/opencode/src/provider/model-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..77bc329aeedcd9735dd4701331a16dd7a50475c6 --- /dev/null +++ b/packages/opencode/src/provider/model-status.ts @@ -0,0 +1,8 @@ +import { Schema } from "effect" + +export { CatalogModelStatus } from "@opencode-ai/core/models-dev" + +export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"]) +export type ModelStatus = typeof ModelStatus.Type + +export * as ProviderModelStatus from "./model-status" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..867ef2ca2fa800b271b42684c26bde7d90776b0a --- /dev/null +++ b/packages/opencode/src/provider/provider.ts @@ -0,0 +1,2072 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import os from "os" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import fuzzysort from "fuzzysort" +import { Config } from "@/config/config" +import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" +import { NoSuchModelError, type Provider as SDK } from "ai" +import { Npm } from "@opencode-ai/core/npm" +import { Hash } from "@opencode-ai/core/util/hash" +import { Plugin } from "../plugin" +import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { type LanguageModelV3 } from "@ai-sdk/provider" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { Auth } from "../auth" +import { Env } from "../env" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { iife } from "@/util/iife" +import { Global } from "@opencode-ai/core/global" +import path from "path" +import { pathToFileURL } from "url" +import { Effect, Layer, Context, Schema, Types } from "effect" +import { EffectBridge } from "@/effect/bridge" +import { InstanceState } from "@/effect/instance-state" +import { EffectPromise } from "@/effect/promise" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { isRecord } from "@/util/record" +import { optional } from "@opencode-ai/core/schema" +import { ProviderTransform } from "./transform" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { ModelStatus } from "./model-status" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { ProviderError } from "./error" + +const OPENAI_HEADER_TIMEOUT_DEFAULT = 300_000 + +function wrapSSE(res: Response, ms: number, ctl: AbortController) { + if (typeof ms !== "number" || ms <= 0) return res + if (!res.body) return res + if (!res.headers.get("content-type")?.includes("text/event-stream")) return res + + const reader = res.body.getReader() + const body = new ReadableStream({ + async pull(ctrl) { + const part = await new Promise>>((resolve, reject) => { + const id = setTimeout(() => { + const err = new ProviderError.ResponseStreamError("SSE read timed out") + ctl.abort(err) + reader.cancel(err).catch(() => {}) + reject(err) + }, ms) + + reader.read().then( + (part) => { + clearTimeout(id) + resolve(part) + }, + (err) => { + clearTimeout(id) + reject(err) + }, + ) + }) + + if (part.done) { + ctrl.close() + return + } + + ctrl.enqueue(part.value) + }, + async cancel(reason) { + ctl.abort(reason) + await reader.cancel(reason) + }, + }) + + return new Response(body, { + headers: new Headers(res.headers), + status: res.status, + statusText: res.statusText, + }) +} + +function timeoutController(ms: number) { + const ctl = new AbortController() + const id = setTimeout(() => ctl.abort(new ProviderError.HeaderTimeoutError(ms)), ms) + return { + signal: ctl.signal, + clear: () => clearTimeout(id), + } +} + +function googleVertexAnthropicBaseURL(project: string | undefined, location: string | undefined) { + if (!project) return + if (location !== "eu" && location !== "us") return + // Continental multi-regions require Regional Endpoint Platform domains. + return `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models` +} + +function googleVertexEndpoint(location: string) { + if (location === "global") return "aiplatform.googleapis.com" + if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com` + return `${location}-aiplatform.googleapis.com` +} + +type BundledSDK = { + languageModel(modelId: string): LanguageModelV3 + chat?: (modelId: string) => LanguageModelV3 + responses?: (modelId: string) => LanguageModelV3 +} + +const BUNDLED_PROVIDERS: Record Promise<(opts: any) => BundledSDK>> = { + "@ai-sdk/amazon-bedrock": () => import("@ai-sdk/amazon-bedrock").then((m) => m.createAmazonBedrock), + "@ai-sdk/amazon-bedrock/mantle": () => import("@ai-sdk/amazon-bedrock/mantle").then((m) => m.createBedrockMantle), + "@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic), + "@ai-sdk/azure": () => import("@ai-sdk/azure").then((m) => m.createAzure), + "@ai-sdk/google": () => import("@ai-sdk/google").then((m) => m.createGoogleGenerativeAI), + "@ai-sdk/google-vertex": () => import("@ai-sdk/google-vertex").then((m) => m.createVertex), + "@ai-sdk/google-vertex/anthropic": () => + import("@ai-sdk/google-vertex/anthropic").then((m) => m.createVertexAnthropic), + "@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI), + "@ai-sdk/openai-compatible": () => import("@ai-sdk/openai-compatible").then((m) => m.createOpenAICompatible), + "@openrouter/ai-sdk-provider": () => import("@openrouter/ai-sdk-provider").then((m) => m.createOpenRouter), + "@ai-sdk/xai": () => import("@ai-sdk/xai").then((m) => m.createXai), + "@ai-sdk/mistral": () => import("@ai-sdk/mistral").then((m) => m.createMistral), + "@ai-sdk/groq": () => import("@ai-sdk/groq").then((m) => m.createGroq), + "@ai-sdk/deepinfra": () => import("@ai-sdk/deepinfra").then((m) => m.createDeepInfra), + "@ai-sdk/cerebras": () => import("@ai-sdk/cerebras").then((m) => m.createCerebras), + "@ai-sdk/cohere": () => import("@ai-sdk/cohere").then((m) => m.createCohere), + "@ai-sdk/gateway": () => import("@ai-sdk/gateway").then((m) => m.createGateway), + "@ai-sdk/togetherai": () => import("@ai-sdk/togetherai").then((m) => m.createTogetherAI), + "@ai-sdk/perplexity": () => import("@ai-sdk/perplexity").then((m) => m.createPerplexity), + "@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel), + "@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba), + "gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab), + "@ai-sdk/github-copilot": () => + import("@opencode-ai/core/github-copilot/copilot-provider").then((m) => m.createOpenaiCompatible), + "venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice), +} + +type CustomModelLoader = (sdk: any, modelID: string, options?: Record, model?: Model) => Promise +type CustomVarsLoader = (options: Record) => Record +type CustomDiscoverModels = () => Promise> +type CustomLoader = (provider: Info) => Effect.Effect<{ + autoload: boolean + getModel?: CustomModelLoader + vars?: CustomVarsLoader + options?: Record + discoverModels?: CustomDiscoverModels +}> + +type CustomDep = { + auth: (id: string) => Effect.Effect + config: () => Effect.Effect + env: () => Effect.Effect> + get: (key: string) => Effect.Effect +} + +function selectAzureLanguageModel(sdk: any, modelID: string, useChat: boolean) { + if (useChat && sdk.chat) return sdk.chat(modelID) + if (sdk.responses) return sdk.responses(modelID) + if (sdk.messages) return sdk.messages(modelID) + if (sdk.chat) return sdk.chat(modelID) + return sdk.languageModel(modelID) +} + +function selectBedrockMantleLanguageModel(sdk: BundledSDK, modelID: string) { + if (modelID === "openai.gpt-oss-safeguard-20b" || modelID === "openai.gpt-oss-safeguard-120b") + return sdk.chat?.(modelID) ?? sdk.languageModel(modelID) + return sdk.responses?.(modelID) ?? sdk.languageModel(modelID) +} + +function custom(dep: CustomDep): Record { + return { + anthropic: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", + }, + }, + }), + opencode: Effect.fnUntraced(function* (input: Info) { + const env = yield* dep.env() + const hasKey = iife(() => { + if (input.env.some((item) => env[item])) return true + return false + }) + const ok = + hasKey || + Boolean(yield* dep.auth(input.id)) || + Boolean((yield* dep.config()).provider?.["opencode"]?.options?.apiKey) + + if (!ok) { + for (const [key, value] of Object.entries(input.models)) { + if (value.cost.input === 0) continue + delete input.models[key] + } + } + + return { + autoload: Object.keys(input.models).length > 0, + options: ok ? {} : { apiKey: "public" }, + } + }), + openai: () => + Effect.succeed({ + autoload: false, + async getModel(sdk: any, modelID: string, _options?: Record) { + return sdk.responses(modelID) + }, + options: { headerTimeout: OPENAI_HEADER_TIMEOUT_DEFAULT }, + }), + meta: () => + Effect.succeed({ + autoload: false, + async getModel(sdk: any, modelID: string, _options?: Record) { + return sdk.responses(modelID) + }, + }), + xai: () => + Effect.succeed({ + autoload: false, + async getModel(sdk: any, modelID: string, _options?: Record) { + return sdk.responses(modelID) + }, + options: {}, + }), + "github-copilot": () => + Effect.succeed({ + autoload: false, + async getModel(sdk: any, modelID: string, _options?: Record, model?: Model) { + if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID) + if (model && "endpoint" in model.api) { + if (model.api.endpoint === "responses" && sdk.responses) return sdk.responses(modelID) + if (model.api.endpoint === "chat" && sdk.chat) return sdk.chat(modelID) + } + const match = /^gpt-(\d+)/.exec(modelID) + if (match && Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")) return sdk.responses(modelID) + return sdk.chat(modelID) + }, + options: {}, + }), + azure: Effect.fnUntraced(function* (provider: Info) { + const env = yield* dep.env() + const auth = yield* dep.auth(provider.id) + const resource = iife(() => { + return [ + provider.options?.resourceName, + auth?.type === "api" ? auth.metadata?.resourceName : undefined, + auth?.type === "oauth" ? auth.accountId : undefined, + env["AZURE_RESOURCE_NAME"], + ].find((name) => typeof name === "string" && name.trim() !== "") + }) + + if (!resource && !provider.options?.baseURL) { + return { + autoload: false, + async getModel() { + throw new Error( + "AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it", + ) + }, + } + } + + return { + autoload: false, + async getModel(sdk: any, modelID: string, options?: Record) { + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) + }, + options: { + resourceName: resource, + }, + vars(_options): Record { + if (resource) { + return { + AZURE_RESOURCE_NAME: resource, + } + } + return {} + }, + } + }), + "azure-cognitive-services": Effect.fnUntraced(function* (provider: Info) { + const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME") + return { + autoload: false, + async getModel(sdk: any, modelID: string, options?: Record) { + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) + }, + options: { + baseURL: resourceName + ? `https://${resourceName}.cognitiveservices.azure.com/openai${provider.options?.useDeploymentBasedUrls ? "" : "/v1"}` + : undefined, + }, + } + }), + "amazon-bedrock": Effect.fnUntraced(function* () { + const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"] + const auth = yield* dep.auth("amazon-bedrock") + const env = yield* dep.env() + + // Region precedence: 1) config file, 2) env var, 3) default + const configRegion = providerConfig?.options?.region + const envRegion = env["AWS_REGION"] + const defaultRegion = configRegion ?? envRegion ?? "us-east-1" + + // Profile: config file takes precedence over env var + const configProfile = providerConfig?.options?.profile + const envProfile = env["AWS_PROFILE"] + const profile = configProfile ?? envProfile + + const awsAccessKeyId = env["AWS_ACCESS_KEY_ID"] + const configApiKey = providerConfig?.options?.apiKey + + // TODO: Using process.env directly because Env.set only updates a process.env shallow copy, + // until the scope of the Env API is clarified (test only or runtime?) + const awsBearerToken = iife(() => { + const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK + if (envToken) return envToken + if (auth?.type === "api") { + process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key + return auth.key + } + return undefined + }) + + const awsWebIdentityTokenFile = env["AWS_WEB_IDENTITY_TOKEN_FILE"] + + const containerCreds = Boolean( + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, + ) + + if ( + !profile && + !awsAccessKeyId && + !awsBearerToken && + !configApiKey && + !awsWebIdentityTokenFile && + !containerCreds + ) + return { autoload: false } + + const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers")) + + const providerOptions: Record = { + region: defaultRegion, + } + + // Only use credential chain if no bearer token exists + // Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens) + if (!awsBearerToken && !configApiKey) { + // Build credential provider options (only pass profile if specified) + const credentialProviderOptions = profile ? { profile } : {} + + providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions) + } + + // Add custom endpoint if specified (endpoint takes precedence over baseURL) + const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL + if (endpoint) { + providerOptions.baseURL = endpoint + } + + return { + autoload: true, + options: providerOptions, + vars(options: Record) { + return { AWS_REGION: options.region ?? defaultRegion } + }, + async getModel(sdk: any, modelID: string, options?: Record, model?: Model) { + if (model?.api.npm === "@ai-sdk/amazon-bedrock/mantle") return selectBedrockMantleLanguageModel(sdk, modelID) + + // Skip region prefixing if model already has a cross-region inference profile prefix + // Models from models.dev may already include prefixes like us., eu., global., etc. + if (modelID.startsWith("arn:")) { + return sdk.languageModel(modelID) + } + + const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."] + if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) { + return sdk.languageModel(modelID) + } + + // Region resolution precedence (highest to lowest): + // 1. options.region from opencode.json provider config + // 2. defaultRegion from AWS_REGION environment variable + // 3. Default "us-east-1" (baked into defaultRegion) + const region = options?.region ?? defaultRegion + + let regionPrefix = region.split("-")[0] + + switch (regionPrefix) { + case "us": { + const modelRequiresPrefix = [ + "nova-micro", + "nova-lite", + "nova-pro", + "nova-premier", + "nova-2", + "claude", + "deepseek.r1", + ].some((m) => modelID.includes(m)) + const isGovCloud = region.startsWith("us-gov") + if (modelRequiresPrefix && !isGovCloud) { + modelID = `${regionPrefix}.${modelID}` + } + break + } + case "eu": { + const regionRequiresPrefix = [ + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-south-1", + "eu-south-2", + ].some((r) => region.includes(r)) + const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) => + modelID.includes(m), + ) + if (regionRequiresPrefix && modelRequiresPrefix) { + modelID = `${regionPrefix}.${modelID}` + } + break + } + case "ap": { + const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region) + const isTokyoRegion = region === "ap-northeast-1" + if ( + isAustraliaRegion && + ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m)) + ) { + regionPrefix = "au" + modelID = `${regionPrefix}.${modelID}` + } else if (isTokyoRegion) { + // Tokyo region uses jp. prefix for cross-region inference + const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) => + modelID.includes(m), + ) + if (modelRequiresPrefix) { + regionPrefix = "jp" + modelID = `${regionPrefix}.${modelID}` + } + } else { + // Other APAC regions use apac. prefix + const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) => + modelID.includes(m), + ) + if (modelRequiresPrefix) { + regionPrefix = "apac" + modelID = `${regionPrefix}.${modelID}` + } + } + break + } + } + + return sdk.languageModel(modelID) + }, + } + }), + llmgateway: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + "X-Source": "opencode", + }, + }, + }), + openrouter: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }, + }, + }), + nvidia: (provider) => + Effect.succeed({ + autoload: provider.source === "config", + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + "X-BILLING-INVOKE-ORIGIN": "OpenCode", + }, + }, + }), + vercel: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "http-referer": "https://opencode.ai/", + "x-title": "opencode", + }, + }, + }), + "google-vertex": Effect.fnUntraced(function* (provider: Info) { + const env = yield* dep.env() + // models.dev advertises GOOGLE_VERTEX_PROJECT for Vertex; keep the wider + // Google Cloud project env names as fallbacks for existing ADC setups. + const project = + provider.options?.project ?? + env["GOOGLE_VERTEX_PROJECT"] ?? + env["GOOGLE_CLOUD_PROJECT"] ?? + env["GCP_PROJECT"] ?? + env["GCLOUD_PROJECT"] + + const location = String( + provider.options?.location ?? + env["GOOGLE_VERTEX_LOCATION"] ?? + env["GOOGLE_CLOUD_LOCATION"] ?? + env["VERTEX_LOCATION"] ?? + "us-central1", + ) + + const autoload = Boolean(project) + if (!autoload) return { autoload: false } + return { + autoload: true, + vars(_options: Record) { + return { + ...(project && { GOOGLE_VERTEX_PROJECT: project }), + GOOGLE_VERTEX_LOCATION: location, + GOOGLE_VERTEX_ENDPOINT: googleVertexEndpoint(location), + } + }, + options: { + project, + location, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const { GoogleAuth } = await import("google-auth-library") + const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }) + const client = await auth.getClient() + const token = await client.getAccessToken() + + const headers = new Headers(init?.headers) + headers.set("Authorization", `Bearer ${token.token}`) + + return fetch(input, { ...init, headers }) + }, + }, + async getModel(sdk: any, modelID: string) { + const id = String(modelID).trim() + return sdk.languageModel(id) + }, + } + }), + "google-vertex-anthropic": Effect.fnUntraced(function* () { + const env = yield* dep.env() + const project = env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"] + const location = env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "global" + const autoload = Boolean(project) + if (!autoload) return { autoload: false } + const baseURL = googleVertexAnthropicBaseURL(project, location) + return { + autoload: true, + options: { + project, + location, + ...(baseURL && { baseURL }), + }, + async getModel(sdk: any, modelID) { + const id = String(modelID).trim() + return sdk.languageModel(id) + }, + } + }), + "sap-ai-core": Effect.fnUntraced(function* () { + const auth = yield* dep.auth("sap-ai-core") + // TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env), + // until the scope of the Env API is clarified (test only or runtime?) + const envServiceKey = iife(() => { + const envAICoreServiceKey = process.env.AICORE_SERVICE_KEY + if (envAICoreServiceKey) return envAICoreServiceKey + if (auth?.type === "api") { + process.env.AICORE_SERVICE_KEY = auth.key + return auth.key + } + return undefined + }) + const deploymentId = process.env.AICORE_DEPLOYMENT_ID + const resourceGroup = process.env.AICORE_RESOURCE_GROUP + + return { + autoload: !!envServiceKey, + options: envServiceKey ? { deploymentId, resourceGroup } : {}, + async getModel(sdk: any, modelID: string) { + return sdk(modelID) + }, + } + }), + zenmux: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }, + }, + }), + gitlab: Effect.fnUntraced(function* (input: Info) { + const { + VERSION: GITLAB_PROVIDER_VERSION, + isWorkflowModel, + discoverWorkflowModels, + } = yield* Effect.promise(() => import("gitlab-ai-provider")) + + const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com" + + const auth = yield* dep.auth(input.id) + const apiKey = auth?.type === "oauth" ? auth.access : auth?.type === "api" ? auth.key : undefined + const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN")) + + const providerConfig = (yield* dep.config()).provider?.["gitlab"] + const directory = yield* InstanceState.directory + + const aiGatewayHeaders = { + "User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, + "anthropic-beta": "context-1m-2025-08-07", + ...providerConfig?.options?.aiGatewayHeaders, + } + + const featureFlags = { + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + ...providerConfig?.options?.featureFlags, + } + + return { + autoload: !!token, + options: { + instanceUrl, + apiKey: token, + aiGatewayHeaders, + featureFlags, + }, + async getModel(sdk: any, modelID: string, options?: Record) { + if (modelID.startsWith("duo-workflow-")) { + const workflowRef = typeof options?.workflowRef === "string" ? options.workflowRef : undefined + // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef + const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow" + const workflowDefinition = + typeof options?.workflowDefinition === "string" ? options.workflowDefinition : undefined + const model = sdk.workflowChat(sdkModelID, { + featureFlags, + workflowDefinition, + }) + if (workflowRef) { + model.selectedModelRef = workflowRef + } + return model + } + return sdk.agenticChat(modelID, { + aiGatewayHeaders, + featureFlags, + }) + }, + async discoverModels(): Promise> { + if (!apiKey) { + return {} + } + + try { + const token = apiKey + const getHeaders = (): Record => + auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` } + + const result = await discoverWorkflowModels({ instanceUrl, getHeaders }, { workingDirectory: directory }) + + if (!result.models.length) { + return {} + } + + const models: Record = {} + for (const m of result.models) { + if (!input.models[m.id]) { + models[m.id] = { + id: ModelV2.ID.make(m.id), + providerID: ProviderV2.ID.make("gitlab"), + name: `Agent Platform (${m.name})`, + family: "", + api: { + id: m.id, + url: instanceUrl, + npm: "gitlab-ai-provider", + }, + status: "active", + headers: {}, + options: { workflowRef: m.ref }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: m.context, output: m.output }, + capabilities: { + temperature: false, + reasoning: true, + attachment: true, + toolcall: true, + input: { + text: true, + audio: false, + image: true, + video: false, + pdf: true, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: false, + }, + release_date: "", + variants: {}, + } + } + } + + return models + } catch (e) { + return {} + } + }, + } + }), + "cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) { + // When baseURL is already configured (e.g. corporate config routing through a proxy/gateway), + // skip the account ID check because the URL is already fully specified. + if (input.options?.baseURL) return { autoload: false } + + const auth = yield* dep.auth(input.id) + const env = yield* dep.env() + const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined) + if (!accountId) + return { + autoload: false, + async getModel() { + throw new Error( + "CLOUDFLARE_ACCOUNT_ID is missing. Set it with: export CLOUDFLARE_ACCOUNT_ID=", + ) + }, + } + + const apiKey = env["CLOUDFLARE_API_KEY"] || (auth?.type === "api" ? auth.key : undefined) + + return { + autoload: !!apiKey, + options: { + apiKey, + headers: { + "User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, + }, + }, + async getModel(sdk: any, modelID: string) { + return sdk.languageModel(modelID) + }, + vars(_options) { + return { + CLOUDFLARE_ACCOUNT_ID: accountId, + } + }, + } + }), + "cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) { + // When baseURL is already configured (e.g. corporate config), skip the ID checks. + if (input.options?.baseURL) return { autoload: false } + + const auth = yield* dep.auth(input.id) + const env = yield* dep.env() + const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined) + // The Cloudflare auth prompt stores this value as gatewayId metadata. + const gateway = env["CLOUDFLARE_GATEWAY_ID"] || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined) + + if (!accountId || !gateway) { + const missing = [ + !accountId ? "CLOUDFLARE_ACCOUNT_ID" : undefined, + !gateway ? "CLOUDFLARE_GATEWAY_ID" : undefined, + ].filter((x): x is string => Boolean(x)) + return { + autoload: false, + async getModel() { + throw new Error( + `${missing.join(" and ")} missing. Set with: ${missing.map((x) => `export ${x}=`).join(" && ")}`, + ) + }, + } + } + + // Get API token from env or auth - required for authenticated gateways + const apiToken = + env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"] || (auth?.type === "api" ? auth.key : undefined) + + if (!apiToken) { + throw new Error( + "CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " + + "Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.", + ) + } + + const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")) + const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")) + const { createOpenAI } = yield* Effect.promise(() => import("ai-gateway-provider/providers/openai")) + const { createAnthropic } = yield* Effect.promise(() => import("ai-gateway-provider/providers/anthropic")) + const { createOpenAICompatible } = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) + + const metadata = iife(() => { + if (input.options?.metadata) return input.options.metadata + try { + return JSON.parse(input.options?.headers?.["cf-aig-metadata"]) + } catch { + return undefined + } + }) + const opts = { + metadata, + cacheTtl: input.options?.cacheTtl, + cacheKey: input.options?.cacheKey, + skipCache: input.options?.skipCache, + collectLog: input.options?.collectLog, + headers: { + "User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, + }, + } + + const aigateway = createAiGateway({ + accountId, + gateway, + apiKey: apiToken, + ...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}), + }) + return { + autoload: true, + async getModel(_sdk: any, modelID: string, _options?: Record) { + // Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5"). + // OpenAI and Anthropic ride their native passthrough routes so agents get the Responses + // and Messages APIs; new OpenAI models reject tools+reasoning_effort on chat completions. + // The passthrough wrappers inject a CF_TEMP_TOKEN sentinel that the gateway strips before + // dispatch, so upstream billing stays on the gateway (Unified Billing / stored BYOK). + if (modelID.startsWith("openai/")) return aigateway(createOpenAI()(modelID.slice("openai/".length))) + // models.dev lists Anthropic ids with dotted versions (claude-haiku-4.5); Anthropic's + // Messages API expects dashed native slugs (claude-haiku-4-5), so translate before passing. + // No native Anthropic slug contains a dot, so the blanket replacement is lossless here - + // unlike OpenAI above, whose native ids (e.g. gpt-4.1) keep their dots and must not be touched. + if (modelID.startsWith("anthropic/")) + return aigateway(createAnthropic()(modelID.slice("anthropic/".length).replaceAll(".", "-"))) + // Workers AI is the only first-party provider whose upstream is Cloudflare itself, so it is + // the only one that should receive the Cloudflare token as its upstream Authorization header. + // The Unified API addresses Workers AI both with the explicit "workers-ai/" prefix and as + // bare "@cf/..." ids. Third-party providers must not receive the token; they rely on the + // gateway's stored/BYOK keys instead. + // Workers AI is Cloudflare's own upstream, so it rides the unified compat route with the + // Cloudflare token as its upstream Authorization header. + const isWorkersAi = modelID.startsWith("workers-ai/") || modelID.startsWith("@cf/") + if (isWorkersAi) return aigateway(createUnified({ apiKey: apiToken })(modelID)) + + // Every other third-party provider (google, xai, alibaba, deepseek, moonshotai, …) is only + // served by Cloudflare's catalog-aware REST API. The universal/compat gateway route rejects + // them with "Invalid provider" (the gateway's compat endpoint doesn't front those upstreams), + // so point an OpenAI-compatible client at the REST endpoint and bind it to the gateway with + // cf-aig-gateway-id — that keeps requests gateway-routed (analytics/caching/BYOK), not a + // bypass. models.dev ids (provider/model, dotted) pass through unchanged. + return createOpenAICompatible({ + name: "cloudflare-ai-gateway", + baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`, + apiKey: apiToken, + headers: { "cf-aig-gateway-id": gateway }, + })(modelID) + }, + options: {}, + } + }), + cerebras: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "X-Cerebras-3rd-Party-Integration": "opencode", + }, + }, + }), + kilo: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }, + }, + }), + "snowflake-cortex": Effect.fnUntraced(function* (input: Info) { + const env = yield* dep.env() + const auth = yield* dep.auth(input.id) + + const account = + env["SNOWFLAKE_ACCOUNT"] ?? + (auth?.type === "api" ? auth.metadata?.account : undefined) ?? + (auth?.type === "oauth" ? auth.accountId : undefined) ?? + input.options?.account + + const envToken = env["SNOWFLAKE_CORTEX_TOKEN"] ?? env["SNOWFLAKE_CORTEX_PAT"] + const apiKeyToken = auth?.type === "api" ? auth.key : undefined + const oauthToken = auth?.type === "oauth" ? auth.access : undefined + const configToken = input.options?.token ?? input.options?.apiKey + + const token = envToken ?? apiKeyToken ?? oauthToken ?? configToken + + if (!account || !token) { + const missing = [!account && "SNOWFLAKE_ACCOUNT", !token && "SNOWFLAKE_CORTEX_TOKEN"].filter(Boolean).join(", ") + return { + autoload: false, + async getModel() { + throw new Error( + `Snowflake Cortex: missing credentials (${missing}). Provide a bearer token (OAuth, JWT, or PAT) via env var, opencode auth, or provider options.`, + ) + }, + } + } + + const baseURL = `https://${account}.snowflakecomputing.com/api/v2/cortex/v1` + + const options: Record = { baseURL, apiKey: token } + + // Only skip provider-level fetch when the token is from OAuth with no override. + // For OAuth tokens, the plugin auth loader's combined fetch handles + // OAuth refresh + snowflake transformations in one place. + // For env/config/API-key tokens, the provider fetch applies snowflake + // transformations directly. + const useOAuthHandler = + oauthToken !== undefined && envToken === undefined && apiKeyToken === undefined && configToken === undefined + if (!useOAuthHandler) { + options.fetch = async (url: RequestInfo | URL, init?: RequestInit) => { + if (init?.body && typeof init.body === "string") { + try { + const body = JSON.parse(init.body) + if ("max_tokens" in body) { + body.max_completion_tokens = body.max_tokens + delete body.max_tokens + init = { ...init, body: JSON.stringify(body) } + } + } catch {} + } + + const response = await fetch(url, init) + + if (!response.ok && response.status === 400) { + try { + const errorData = await response.clone().json() + const errorMessage = String(errorData.message || errorData.error || "") + if (errorMessage.toLowerCase().includes("conversation complete")) { + return new Response( + JSON.stringify({ + choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }], + }), + { status: 200, headers: new Headers({ "content-type": "application/json" }) }, + ) + } + } catch {} + } + + if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) { + const reader = response.body.getReader() + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const stream = new ReadableStream({ + async pull(ctrl) { + const { done, value } = await reader.read() + if (done) { + ctrl.close() + return + } + const text = decoder.decode(value, { stream: true }) + ctrl.enqueue(encoder.encode(text.replace(/"role"\s*:\s*""/g, '"role":"assistant"'))) + }, + cancel() { + reader.cancel() + }, + }) + return new Response(stream, { headers: response.headers, status: response.status }) + } + + return response + } + } + + return { + autoload: input.source === "config", + options, + } + }), + } +} + +const ProviderApiInfo = Schema.Struct({ + id: Schema.String, + url: Schema.String, + npm: Schema.String, +}) + +const ProviderModalities = Schema.Struct({ + text: Schema.Boolean, + audio: Schema.Boolean, + image: Schema.Boolean, + video: Schema.Boolean, + pdf: Schema.Boolean, +}) + +const ProviderInterleavedField = Schema.Union([ + Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]), + Schema.String, +]) + +const ProviderInterleaved = Schema.Union([ + Schema.Boolean, + Schema.Struct({ + field: ProviderInterleavedField, + }), +]) + +const ProviderCapabilities = Schema.Struct({ + temperature: Schema.Boolean, + reasoning: Schema.Boolean, + attachment: Schema.Boolean, + toolcall: Schema.Boolean, + input: ProviderModalities, + output: ProviderModalities, + interleaved: ProviderInterleaved, +}) + +const ProviderCacheCost = Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, +}) + +const ProviderCostTier = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + cache: ProviderCacheCost, + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Finite, + }), +}) + +const ProviderCost = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + cache: ProviderCacheCost, + tiers: optional(Schema.Array(ProviderCostTier)), + experimentalOver200K: optional( + Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + cache: ProviderCacheCost, + }), + ), +}) + +const ProviderLimit = Schema.Struct({ + context: Schema.Finite, + input: optional(Schema.Finite), + output: Schema.Finite, +}) + +export const Model = Schema.Struct({ + id: ModelV2.ID, + providerID: ProviderV2.ID, + api: ProviderApiInfo, + name: Schema.String, + family: optional(Schema.String), + capabilities: ProviderCapabilities, + cost: ProviderCost, + limit: ProviderLimit, + status: ModelStatus, + options: Schema.Record(Schema.String, Schema.Any), + headers: Schema.Record(Schema.String, Schema.String), + release_date: Schema.String, + variants: optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), +}).annotate({ identifier: "Model" }) +export type Model = Types.DeepMutable> + +export const Info = Schema.Struct({ + id: ProviderV2.ID, + name: Schema.String, + source: Schema.Literals(["env", "config", "custom", "api"]), + env: Schema.Array(Schema.String), + key: optional(Schema.String), + options: Schema.Record(Schema.String, Schema.Any), + models: Schema.Record(Schema.String, Model), +}).annotate({ identifier: "Provider" }) +export type Info = Types.DeepMutable> + +const DefaultModelIDs = Schema.Record(Schema.String, Schema.String) + +export const ListResult = Schema.Struct({ + all: Schema.Array(Info), + default: DefaultModelIDs, + connected: Schema.Array(Schema.String), +}) +export type ListResult = Types.DeepMutable> + +export const ConfigProvidersResult = Schema.Struct({ + providers: Schema.Array(Info), + default: DefaultModelIDs, +}) +export type ConfigProvidersResult = Types.DeepMutable> + +export function toPublicInfo(provider: Info): Info { + return JSON.parse( + JSON.stringify( + { + ...provider, + models: Object.fromEntries(Object.entries(provider.models).filter(([, model]) => Schema.is(Model)(model))), + }, + (_, value) => { + if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined + if (typeof value === "bigint") return value.toString() + return value + }, + ), + ) +} + +export function defaultModelIDs }>(providers: Record) { + return mapValues(providers, (item) => sort(Object.values(item.models))[0].id) +} + +export class ModelNotFoundError extends Schema.TaggedErrorClass()("ProviderModelNotFoundError", { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + suggestions: Schema.optional(Schema.Array(Schema.String)), + cause: Schema.optional(Schema.Defect()), +}) { + override get message() { + const suggestions = this.suggestions?.length ? ` Did you mean: ${this.suggestions.join(", ")}?` : "" + return `Model not found: ${this.providerID}/${this.modelID}.${suggestions}` + } + + static isInstance(input: unknown): input is ModelNotFoundError { + return input instanceof ModelNotFoundError + } +} + +export class InitError extends Schema.TaggedErrorClass()("ProviderInitError", { + providerID: ProviderV2.ID, + cause: Schema.optional(Schema.Defect()), +}) { + override get message() { + return `Failed to initialize provider: ${this.providerID}` + } + + static isInstance(input: unknown): input is InitError { + return input instanceof InitError + } +} + +export class NoProvidersError extends Schema.TaggedErrorClass()("ProviderNoProvidersError", {}) { + override get message() { + return "No providers are available" + } + + static isInstance(input: unknown): input is NoProvidersError { + return input instanceof NoProvidersError + } +} + +export class NoModelsError extends Schema.TaggedErrorClass()("ProviderNoModelsError", { + providerID: ProviderV2.ID, +}) { + override get message() { + return `No models are available for provider: ${this.providerID}` + } + + static isInstance(input: unknown): input is NoModelsError { + return input instanceof NoModelsError + } +} + +export type DefaultModelError = ModelNotFoundError | NoProvidersError | NoModelsError +export type Error = ModelNotFoundError | InitError | NoProvidersError | NoModelsError + +export interface Interface { + readonly list: () => Effect.Effect> + readonly getProvider: (providerID: ProviderV2.ID) => Effect.Effect + readonly getModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect + readonly getLanguage: (model: Model) => Effect.Effect + readonly closest: ( + providerID: ProviderV2.ID, + query: string[], + ) => Effect.Effect<{ providerID: ProviderV2.ID; modelID: string } | undefined> + readonly getSmallModel: (providerID: ProviderV2.ID) => Effect.Effect + readonly defaultModel: () => Effect.Effect<{ providerID: ProviderV2.ID; modelID: ModelV2.ID }, DefaultModelError> +} + +interface State { + models: Map + providers: Record + catalog: Record + sdk: Map + modelLoaders: Record + varsLoaders: Record +} + +export class Service extends Context.Service()("@opencode/Provider") {} + +export const use = serviceUse(Service) + +function cost(c: ModelsDev.Model["cost"]): Model["cost"] { + const result: Model["cost"] = { + input: c?.input ?? 0, + output: c?.output ?? 0, + cache: { + read: c?.cache_read ?? 0, + write: c?.cache_write ?? 0, + }, + } + if (c?.tiers) { + result.tiers = c.tiers.map((item) => ({ + input: item.input, + output: item.output, + cache: { + read: item.cache_read ?? 0, + write: item.cache_write ?? 0, + }, + tier: item.tier, + })) + } + if (c?.context_over_200k) { + result.experimentalOver200K = { + cache: { + read: c.context_over_200k.cache_read ?? 0, + write: c.context_over_200k.cache_write ?? 0, + }, + input: c.context_over_200k.input, + output: c.context_over_200k.output, + } + } + return result +} + +// Cloudflare AI Gateway routes OpenAI and Anthropic models through their native +// passthrough SDKs (Responses / Messages APIs). Resolving the native npm before +// variants are computed makes reasoning variants produce payloads the native +// SDKs understand (e.g. anthropic `effort` instead of compat `reasoningEffort`). +function cloudflareGatewayNpm(providerID: string, modelID: string) { + if (providerID !== "cloudflare-ai-gateway") return undefined + if (modelID.startsWith("openai/")) return "@ai-sdk/openai" + if (modelID.startsWith("anthropic/")) return "@ai-sdk/anthropic" + return undefined +} + +function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { + const base: Model = { + id: ModelV2.ID.make(model.id), + providerID: ProviderV2.ID.make(provider.id), + name: model.name, + family: model.family, + api: { + id: model.id, + url: model.provider?.api ?? provider.api ?? "", + npm: + cloudflareGatewayNpm(provider.id, model.id) ?? + model.provider?.npm ?? + provider.npm ?? + "@ai-sdk/openai-compatible", + }, + status: model.status ?? "active", + headers: {}, + options: {}, + cost: cost(model.cost), + limit: { + context: model.limit.context, + input: model.limit.input, + output: model.limit.output, + }, + capabilities: { + temperature: model.temperature ?? false, + reasoning: model.reasoning ?? false, + attachment: model.attachment ?? false, + toolcall: model.tool_call ?? true, + input: { + text: model.modalities?.input?.includes("text") ?? false, + audio: model.modalities?.input?.includes("audio") ?? false, + image: model.modalities?.input?.includes("image") ?? false, + video: model.modalities?.input?.includes("video") ?? false, + pdf: model.modalities?.input?.includes("pdf") ?? false, + }, + output: { + text: model.modalities?.output?.includes("text") ?? false, + audio: model.modalities?.output?.includes("audio") ?? false, + image: model.modalities?.output?.includes("image") ?? false, + video: model.modalities?.output?.includes("video") ?? false, + pdf: model.modalities?.output?.includes("pdf") ?? false, + }, + interleaved: typeof model.interleaved === "string" ? { field: model.interleaved } : (model.interleaved ?? false), + }, + release_date: model.release_date ?? "", + variants: {}, + } + + const variants = ProviderTransform.reasoningVariants(model, base) ?? ProviderTransform.variants(base) + + return { + ...base, + variants: mapValues(variants, (v) => v), + } +} + +export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { + const models: Record = {} + for (const [key, model] of Object.entries(provider.models)) { + models[key] = fromModelsDevModel(provider, model) + for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) { + const id = `${model.id}-${mode}` + const base = fromModelsDevModel(provider, model) + models[id] = { + ...base, + id: ModelV2.ID.make(id), + name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`, + cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost, + options: modeOptions(base, opts.provider?.body), + headers: opts.provider?.headers ?? base.headers, + } + } + } + return { + id: ProviderV2.ID.make(provider.id), + source: "custom", + name: provider.name, + env: [...(provider.env ?? [])], + options: {}, + models, + } +} + +function modeOptions(model: Model, body: Record | undefined) { + if (!body) return model.options + const options = Object.fromEntries( + Object.entries(body).map(([key, value]) => [key.replace(/_([a-z])/g, (_, char) => char.toUpperCase()), value]), + ) + const reasoning = body.reasoning + if (model.api.npm !== "@ai-sdk/openai" || !isRecord(reasoning) || typeof reasoning.mode !== "string") return options + const { reasoning: _, ...rest } = options + return { ...rest, reasoningMode: reasoning.mode } +} + +function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enableExperimentalModels: boolean) { + const available = provider + ? Object.keys(provider.models).filter((id) => { + const model = provider.models[id] + if (model.status === "deprecated") return false + if (model.status === "alpha" && !enableExperimentalModels) return false + return true + }) + : [] + const fuzzy = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 }).map((m) => m.target) + if (fuzzy.length) return fuzzy + const query = modelID + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((part) => part.length > 1) + return sortBy( + available + .map((id) => ({ + id, + score: query.filter((part) => id.toLowerCase().includes(part)).length, + })) + .filter((item) => item.score > 0), + [(item) => item.score, "desc"], + [(item) => item.id, "asc"], + ) + .slice(0, 3) + .map((item) => item.id) +} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const config = yield* Config.Service + const auth = yield* Auth.Service + const env = yield* Env.Service + const plugin = yield* Plugin.Service + const modelsDevSvc = yield* ModelsDev.Service + const runtimeFlags = yield* RuntimeFlags.Service + + const state = yield* InstanceState.make(() => + Effect.gen(function* () { + const bridge = yield* EffectBridge.make() + const cfg = yield* config.get() + const modelsDev = yield* modelsDevSvc.get() + const catalog = mapValues(modelsDev, fromModelsDevProvider) + const database = mapValues(catalog, toPublicInfo) + + const providers: Record = {} as Record + const languages = new Map() + const modelLoaders: { + [providerID: string]: CustomModelLoader + } = {} + const varsLoaders: { + [providerID: string]: CustomVarsLoader + } = {} + const sdk = new Map() + const discoveryLoaders: { + [providerID: string]: CustomDiscoverModels + } = {} + const dep = { + auth: (id: string) => auth.get(id).pipe(Effect.orDie), + config: () => config.get(), + env: () => env.all(), + get: (key: string) => env.get(key), + } + + function mergeProvider(providerID: ProviderV2.ID, provider: Partial) { + const existing = providers[providerID] + if (existing) { + // @ts-expect-error + providers[providerID] = mergeDeep(existing, provider) + return + } + const match = database[providerID] + if (!match) return + // @ts-expect-error + providers[providerID] = mergeDeep(match, provider) + } + + // load plugins first so config() hook runs before reading cfg.provider + const plugins = yield* plugin.list() + + // now read config providers - includes any modifications from plugin config() hook + const configProviders = Object.entries(cfg.provider ?? {}) + const disabled = new Set(cfg.disabled_providers ?? []) + const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null + + function isProviderAllowed(providerID: ProviderV2.ID): boolean { + if (enabled && !enabled.has(providerID)) return false + if (disabled.has(providerID)) return false + return true + } + + for (const hook of plugins) { + const p = hook.provider + const models = p?.models + if (!p || !models) continue + + const providerID = ProviderV2.ID.make(p.id) + if (disabled.has(providerID)) continue + + const provider = database[providerID] + if (!provider) continue + const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) + + provider.models = yield* Effect.promise(async () => { + const next = await models(toPublicInfo(provider), { auth: pluginAuth }) + return Object.fromEntries( + Object.entries(next).map(([id, model]) => [ + id, + { + ...model, + id: ModelV2.ID.make(id), + providerID, + }, + ]), + ) + }) + } + + // extend database from config + for (const [providerID, provider] of configProviders) { + const existing = database[providerID] + const parsed: Info = { + id: ProviderV2.ID.make(providerID), + name: provider.name ?? existing?.name ?? providerID, + env: provider.env ?? existing?.env ?? [], + options: mergeDeep(existing?.options ?? {}, provider.options ?? {}), + source: "config", + models: existing?.models ?? {}, + } + + for (const [modelID, model] of Object.entries(provider.models ?? {})) { + const existingModel = parsed.models[model.id ?? modelID] + const apiID = model.id ?? existingModel?.api.id ?? modelID + const apiNpm = + model.provider?.npm ?? + provider.npm ?? + existingModel?.api.npm ?? + // Config-defined gateway models bypass fromModelsDevModel, so resolve the + // native passthrough npm here before falling back to the catalog default. + cloudflareGatewayNpm(providerID, apiID) ?? + modelsDev[providerID]?.npm ?? + "@ai-sdk/openai-compatible" + const name = iife(() => { + if (model.name) return model.name + if (model.id && model.id !== modelID) return modelID + return existingModel?.name ?? modelID + }) + const parsedModel: Model = { + id: ModelV2.ID.make(modelID), + api: { + id: apiID, + npm: apiNpm, + url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "", + }, + status: model.status ?? existingModel?.status ?? "active", + name, + providerID: ProviderV2.ID.make(providerID), + capabilities: { + temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, + reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, + attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, + toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, + input: { + text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, + audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false, + image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false, + video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false, + pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false, + }, + output: { + text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true, + audio: + model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false, + image: + model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false, + video: + model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false, + pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false, + }, + interleaved: + (typeof model.interleaved === "string" ? { field: model.interleaved } : model.interleaved) ?? + existingModel?.capabilities.interleaved ?? + (!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek") + ? { field: "reasoning_content" } + : false), + }, + cost: { + input: model?.cost?.input ?? existingModel?.cost?.input ?? 0, + output: model?.cost?.output ?? existingModel?.cost?.output ?? 0, + cache: { + read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0, + write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, + }, + }, + options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), + limit: { + context: model.limit?.context ?? existingModel?.limit?.context ?? 0, + input: model.limit?.input ?? existingModel?.limit?.input, + output: model.limit?.output ?? existingModel?.limit?.output ?? 0, + }, + headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), + family: model.family ?? existingModel?.family ?? "", + release_date: model.release_date ?? existingModel?.release_date ?? "", + variants: {}, + } + const variants = + existingModel?.api.npm === parsedModel.api.npm + ? (existingModel.variants ?? ProviderTransform.variants(parsedModel)) + : ProviderTransform.variants(parsedModel) + const merged = mergeDeep(variants, model.variants ?? {}) + parsedModel.variants = mapValues( + pickBy(merged, (v) => !v.disabled), + (v) => omit(v, ["disabled"]), + ) + parsed.models[modelID] = parsedModel + } + database[providerID] = parsed + } + + // load env + const envs = yield* env.all() + for (const [id, provider] of Object.entries(database)) { + const providerID = ProviderV2.ID.make(id) + if (disabled.has(providerID)) continue + const apiKey = provider.env.map((item) => envs[item]).find(Boolean) + if (!apiKey) continue + mergeProvider(providerID, { + source: "env", + key: provider.env.length === 1 ? apiKey : undefined, + }) + } + + // load apikeys + const auths = yield* auth.all().pipe(Effect.orDie) + for (const [id, provider] of Object.entries(auths)) { + const providerID = ProviderV2.ID.make(id) + if (disabled.has(providerID)) continue + if (provider.type === "api") { + mergeProvider(providerID, { + source: "api", + key: provider.key, + }) + } + } + + // plugin auth loader - database now has entries for config providers + for (const plugin of plugins) { + if (!plugin.auth) continue + const providerID = ProviderV2.ID.make(plugin.auth.provider) + if (disabled.has(providerID)) continue + + const stored = yield* auth.get(providerID).pipe(Effect.orDie) + if (!stored) continue + if (!plugin.auth.loader) continue + + const options = yield* Effect.promise(() => + plugin.auth!.loader!( + () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, + toPublicInfo(database[plugin.auth!.provider]), + ), + ) + const opts = options ?? {} + const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } + mergeProvider(providerID, patch) + } + + for (const [id, fn] of Object.entries(custom(dep))) { + const providerID = ProviderV2.ID.make(id) + if (disabled.has(providerID)) continue + const data = database[providerID] + if (!data) { + continue + } + const result = yield* fn(data) + if (result && (result.autoload || providers[providerID])) { + if (result.getModel) modelLoaders[providerID] = result.getModel + if (result.vars) varsLoaders[providerID] = result.vars + if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels + const opts = result.options ?? {} + const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } + mergeProvider(providerID, patch) + } + } + + // load config - re-apply with updated data + for (const [id, provider] of configProviders) { + const providerID = ProviderV2.ID.make(id) + const partial: Partial = { source: "config" } + if (provider.env) partial.env = provider.env + if (provider.name) partial.name = provider.name + if (provider.options) partial.options = provider.options + mergeProvider(providerID, partial) + } + + const gitlab = ProviderV2.ID.make("gitlab") + if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) { + yield* Effect.promise(async () => { + try { + const discovered = await discoveryLoaders[gitlab]() + for (const [modelID, model] of Object.entries(discovered)) { + if (!providers[gitlab].models[modelID]) { + providers[gitlab].models[modelID] = model + } + } + } catch (e) {} + }) + } + + for (const [id, provider] of Object.entries(providers)) { + const providerID = ProviderV2.ID.make(id) + if (!isProviderAllowed(providerID)) { + delete providers[providerID] + continue + } + + const configProvider = cfg.provider?.[providerID] + + for (const [modelID, model] of Object.entries(provider.models)) { + model.api.id = model.api.id ?? model.id ?? modelID + + if ( + // These chat aliases are invalid for the special handling in the + // built-in providers below, but custom providers may support them. + (modelID === "gpt-5-chat-latest" && + (providerID === ProviderV2.ID.openai || + providerID === ProviderV2.ID.githubCopilot || + providerID === ProviderV2.ID.openrouter)) || + (providerID === ProviderV2.ID.openrouter && modelID === "openai/gpt-5-chat") + ) + delete provider.models[modelID] + if (model.status === "alpha" && !runtimeFlags.enableExperimentalModels) delete provider.models[modelID] + if (model.status === "deprecated") delete provider.models[modelID] + if ( + (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) || + (configProvider?.whitelist && !configProvider.whitelist.includes(modelID)) + ) + delete provider.models[modelID] + + if (model.variants === undefined) { + model.variants = mapValues(ProviderTransform.variants(model), (v) => v) + } + + const configVariants = configProvider?.models?.[modelID]?.variants + if (configVariants && model.variants) { + const merged = mergeDeep(model.variants, configVariants) + model.variants = mapValues( + pickBy(merged, (v) => !v.disabled), + (v) => omit(v, ["disabled"]), + ) + } + } + + if (Object.keys(provider.models).length === 0) { + delete providers[providerID] + continue + } + } + + return { + models: languages, + providers, + catalog, + sdk, + modelLoaders, + varsLoaders, + } + }), + ) + + const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) + + async function resolveSDK(model: Model, s: State, envs: Record) { + try { + const provider = s.providers[model.providerID] + const options = { ...provider.options } + + if ( + model.providerID === "google-vertex" && + model.api.npm === "@ai-sdk/google-vertex/anthropic" && + !options.baseURL + ) { + const baseURL = googleVertexAnthropicBaseURL( + typeof options.project === "string" ? options.project : undefined, + typeof options.location === "string" ? options.location : undefined, + ) + if (baseURL) options.baseURL = baseURL + } + + if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) { + delete options.fetch + } + + if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) { + options["includeUsage"] = true + } + + const baseURL = iife(() => { + let url = + typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url + if (!url) return + + const loader = s.varsLoaders[model.providerID] + if (loader) { + const vars = loader(options) + for (const [key, value] of Object.entries(vars)) { + const field = "${" + key + "}" + url = url.replaceAll(field, value) + } + } + + url = url.replace(/\$\{([^}]+)\}/g, (item, key) => { + const val = envs[String(key)] + return val ?? item + }) + return url + }) + + if (baseURL !== undefined) options["baseURL"] = baseURL + if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key + if (model.headers) + options["headers"] = { + ...options["headers"], + ...model.headers, + } + + const key = Hash.fast( + JSON.stringify({ + providerID: model.providerID, + npm: model.api.npm, + options, + }), + ) + const existing = s.sdk.get(key) + if (existing) return existing + + const customFetch = options["fetch"] + const chunkTimeout = options["chunkTimeout"] ?? 300_000 + const headerTimeout = options["headerTimeout"] ?? 300_000 + delete options["chunkTimeout"] + delete options["headerTimeout"] + + options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { + const fetchFn = customFetch ?? fetch + const opts = init ?? {} + const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined + const headerTimeoutMs = headerTimeout === false ? undefined : headerTimeout + const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined + const signals: AbortSignal[] = [] + + if (opts.signal) signals.push(opts.signal) + if (chunkAbortCtl) signals.push(chunkAbortCtl.signal) + if (headerTimeoutCtl) signals.push(headerTimeoutCtl.signal) + if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false) + signals.push(AbortSignal.timeout(options["timeout"])) + + const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) + if (combined) opts.signal = combined + + const res = await fetchFn(input, { + ...opts, + // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 + timeout: false, + }).finally(() => headerTimeoutCtl?.clear()) + + if (!chunkAbortCtl) return res + return wrapSSE(res, chunkTimeout, chunkAbortCtl) + } + + const bundledLoader = BUNDLED_PROVIDERS[model.api.npm] + if (bundledLoader) { + const factory = await bundledLoader() + const loaded = factory({ + name: model.providerID, + ...options, + }) + s.sdk.set(key, loaded) + return loaded as SDK + } + + const installedPath = await (async () => { + if (model.api.npm.startsWith("file://")) { + return model.api.npm + } + const item = await Npm.add(model.api.npm) + if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`) + return item.entrypoint + })() + + // `installedPath` is a local entry path or an existing `file://` URL. Normalize + // only path inputs so Node on Windows accepts the dynamic import. + const importSpec = installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href + const mod = await import(importSpec) + + const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] + const loaded = fn({ + name: model.providerID, + ...options, + }) + s.sdk.set(key, loaded) + return loaded as SDK + } catch (e) { + throw new InitError({ providerID: model.providerID, cause: e }) + } + } + + const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderV2.ID) => + InstanceState.use(state, (s) => s.providers[providerID]), + ) + + const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderV2.ID, modelID: ModelV2.ID) { + const s = yield* InstanceState.get(state) + const provider = s.providers[providerID] + if (!provider) { + const catalogProvider = s.catalog[providerID] + const suggestions = catalogProvider + ? modelSuggestions(catalogProvider, modelID, runtimeFlags.enableExperimentalModels) + : fuzzysort + .go(providerID, Object.keys({ ...s.catalog, ...s.providers }), { limit: 3, threshold: -10000 }) + .map((m) => m.target) + return yield* new ModelNotFoundError({ providerID, modelID, suggestions }) + } + + const info = provider.models[modelID] + if (!info) { + const current = modelSuggestions(provider, modelID, runtimeFlags.enableExperimentalModels) + const suggestions = current.length + ? current + : modelSuggestions(s.catalog[providerID], modelID, runtimeFlags.enableExperimentalModels) + return yield* new ModelNotFoundError({ providerID, modelID, suggestions }) + } + return info + }) + + const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) { + const s = yield* InstanceState.get(state) + const envs = yield* env.all() + const key = `${model.providerID}/${model.id}` + if (s.models.has(key)) return s.models.get(key)! + + const provider = s.providers[model.providerID] + return yield* EffectPromise.refineRejection( + async () => { + const sdk = await resolveSDK(model, s, envs) + const language = s.modelLoaders[model.providerID] + ? await s.modelLoaders[model.providerID]( + sdk, + model.api.id, + { + ...provider.options, + ...model.options, + }, + model, + ) + : sdk.languageModel(model.api.id) + s.models.set(key, language) + return language + }, + (cause) => + cause instanceof NoSuchModelError + ? new ModelNotFoundError({ modelID: model.id, providerID: model.providerID, cause }) + : undefined, + ) + }) + + const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderV2.ID, query: string[]) { + const s = yield* InstanceState.get(state) + const provider = s.providers[providerID] + if (!provider) return undefined + for (const item of query) { + for (const modelID of Object.keys(provider.models)) { + if (modelID.includes(item)) return { providerID, modelID } + } + } + return undefined + }) + + const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderV2.ID) { + const cfg = yield* config.get() + + if (cfg.small_model) { + const parsed = parseModel(cfg.small_model) + return yield* getModel(parsed.providerID, parsed.modelID).pipe( + Effect.catchTag("ProviderModelNotFoundError", () => Effect.succeed(undefined)), + ) + } + + const s = yield* InstanceState.get(state) + const provider = s.providers[providerID] + if (!provider) return undefined + + const experimental = yield* plugin.trigger<"experimental.provider.small_model">( + "experimental.provider.small_model", + { provider: toPublicInfo(provider) }, + { model: undefined }, + ) + if (experimental.model) { + return { + ...experimental.model, + id: ModelV2.ID.make(experimental.model.id), + providerID: ProviderV2.ID.make(experimental.model.providerID), + } + } + + // TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments. + if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) { + return undefined + } + + const priority = providerID.startsWith("opencode") + ? ["gpt-nano"] + : providerID.startsWith("github-copilot") + ? ["gpt-mini", ...smallModelFamilyPriority] + : smallModelFamilyPriority + const models = sortBy( + Object.values(provider.models), + [(model) => model.release_date, "desc"], + [(model) => model.id, "desc"], + ) + for (const family of priority) { + const candidates = models.filter((model) => model.family === family) + if (providerID === ProviderV2.ID.amazonBedrock) { + const crossRegionPrefixes = ["global.", "us.", "eu."] + + const globalMatch = candidates.find((model) => model.id.startsWith("global.")) + if (globalMatch) return globalMatch + + const region = provider.options?.region + if (region) { + const regionPrefix = region.split("-")[0] + if (regionPrefix === "us" || regionPrefix === "eu") { + const regionalMatch = candidates.find((model) => model.id.startsWith(`${regionPrefix}.`)) + if (regionalMatch) return regionalMatch + } + } + + const unprefixed = candidates.find((model) => !crossRegionPrefixes.some((p) => model.id.startsWith(p))) + if (unprefixed) return unprefixed + continue + } + if (candidates[0]) return candidates[0] + } + + return undefined + }) + + const defaultModel = Effect.fn("Provider.defaultModel")(function* () { + const cfg = yield* config.get() + if (cfg.model) return parseModel(cfg.model) + + const s = yield* InstanceState.get(state) + const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe( + Effect.map((x): { providerID: ProviderV2.ID; modelID: ModelV2.ID }[] => { + if (!isRecord(x) || !Array.isArray(x.recent)) return [] + return x.recent.flatMap((item) => { + if (!isRecord(item)) return [] + if (typeof item.providerID !== "string") return [] + if (typeof item.modelID !== "string") return [] + return [{ providerID: ProviderV2.ID.make(item.providerID), modelID: ModelV2.ID.make(item.modelID) }] + }) + }), + Effect.catch(() => Effect.succeed([] as { providerID: ProviderV2.ID; modelID: ModelV2.ID }[])), + ) + for (const entry of recent) { + const provider = s.providers[entry.providerID] + if (!provider) continue + if (!provider.models[entry.modelID]) continue + return { providerID: entry.providerID, modelID: entry.modelID } + } + + const configured = Object.keys(cfg.provider ?? {}) + const provider = Object.values(s.providers).find((p) => configured.length === 0 || configured.includes(p.id)) + if (!provider) return yield* new NoProvidersError() + const [model] = sort(Object.values(provider.models)) + if (!model) return yield* new NoModelsError({ providerID: provider.id }) + return { + providerID: provider.id, + modelID: model.id, + } + }) + + return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel }) + }), +) + +const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] +const smallModelFamilyPriority = ["gemini-flash", "gpt-nano", "claude-haiku"] +export function sort(models: T[]) { + return sortBy( + models, + [(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"], + [(model) => (model.id.includes("latest") ? 0 : 1), "asc"], + [(model) => model.id, "desc"], + ) +} + +export function parseModel(model: string) { + const [providerID, ...rest] = model.split("/") + return { + providerID: ProviderV2.ID.make(providerID), + modelID: ModelV2.ID.make(rest.join("/")), + } +} + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, Config.node, Auth.node, Env.node, Plugin.node, ModelsDev.node, RuntimeFlags.node], +}) + +export * as Provider from "./provider" diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0be2d1537517bd9127d283e892ab97612c59689 --- /dev/null +++ b/packages/opencode/src/provider/transform.ts @@ -0,0 +1,1909 @@ +import type { ModelMessage, ToolResultPart } from "ai" +import { mergeDeep, unique } from "remeda" +import type { JSONSchema7 } from "@ai-sdk/provider" +import type * as Provider from "./provider" +import type * as ModelsDev from "@opencode-ai/core/models-dev" +import { iife } from "@/util/iife" + +type Modality = NonNullable["input"][number] + +function mimeToModality(mime: string): Modality | undefined { + if (mime.startsWith("image/")) return "image" + if (mime.startsWith("audio/")) return "audio" + if (mime.startsWith("video/")) return "video" + if (mime === "application/pdf") return "pdf" + return undefined +} + +export const OUTPUT_TOKEN_MAX = 32_000 + +// OpenAI Responses `include` value that returns the encrypted reasoning state +// needed for stateless multi-turn reasoning (store: false). Hoisted so every +// branch that requests it stays in lockstep. +const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const + +export function sanitizeSurrogates(content: string) { + return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const value = id.toLowerCase() + return value.includes("kimi") || value.includes("moonshot") + }) + ) + return true + const url = model.api.url.toLowerCase() + return ["api.kimi.com", "api.moonshot.ai", "api.moonshot.cn", "api.moonshotai.cn"].some((host) => url.includes(host)) +} + +// Maps npm package to the key the AI SDK expects for providerOptions +function sdkKey(npm: string): string | undefined { + switch (npm) { + case "@ai-sdk/github-copilot": + return "copilot" + case "@ai-sdk/azure": + return "azure" + case "@ai-sdk/openai": + return "openai" + case "@ai-sdk/amazon-bedrock/mantle": + return "openai" + case "@ai-sdk/amazon-bedrock": + return "bedrock" + case "@ai-sdk/anthropic": + case "@ai-sdk/google-vertex/anthropic": + return "anthropic" + case "@ai-sdk/google-vertex": + return "vertex" + case "@ai-sdk/google": + return "google" + case "@ai-sdk/alibaba": + return "alibaba" + case "@ai-sdk/cerebras": + return "cerebras" + case "@ai-sdk/cohere": + return "cohere" + case "@ai-sdk/deepinfra": + return "deepinfra" + case "@ai-sdk/groq": + return "groq" + case "@ai-sdk/mistral": + return "mistral" + case "@ai-sdk/perplexity": + return "perplexity" + case "@ai-sdk/togetherai": + return "togetherai" + case "@ai-sdk/vercel": + return "vercel" + case "@ai-sdk/xai": + return "xai" + case "venice-ai-sdk-provider": + return "venice" + case "@ai-sdk/gateway": + return "gateway" + case "@openrouter/ai-sdk-provider": + return "openrouter" + case "merge-gateway-ai-sdk-provider": + return "mergeGateway" + case "ai-gateway-provider": + // ai-gateway-provider/unified wraps createOpenAICompatible({ name: "Unified" }), + // and @ai-sdk/openai-compatible parses compatibleOptions from one of + // "openai-compatible" / "openaiCompatible" / "Unified" / "unified". The + // "openai-compatible" key emits a deprecation warning at runtime, so we + // pick the camelCase form the SDK now treats as canonical. + return "openaiCompatible" + } + return undefined +} + +// TODO: fix this stupid inefficient dogshit function +function normalizeMessages( + msgs: ModelMessage[], + model: Provider.Model, + _options: Record, +): ModelMessage[] { + const sanitizeToolResultOutput = (content: ToolResultPart) => { + if (content.output.type === "text" || content.output.type === "error-text") { + content.output.value = sanitizeSurrogates(content.output.value) + } + if (content.output.type === "content") { + content.output.value = content.output.value.map((item) => { + if (item.type === "text") { + item.text = sanitizeSurrogates(item.text) + } + return item + }) + } + return content + } + + msgs = msgs.map((msg) => { + switch (msg.role) { + case "tool": + if (!Array.isArray(msg.content)) return msg + msg.content = msg.content.map((content) => { + if (content.type === "tool-result") { + return sanitizeToolResultOutput(content) + } + return content + }) + return msg + + case "system": + msg.content = sanitizeSurrogates(msg.content) + return msg + + case "user": + if (typeof msg.content === "string") { + msg.content = sanitizeSurrogates(msg.content) + } else { + msg.content = msg.content.map((content) => { + if (content.type === "text") { + content.text = sanitizeSurrogates(content.text) + } + return content + }) + } + return msg + + case "assistant": + if (typeof msg.content === "string") { + msg.content = sanitizeSurrogates(msg.content) + } else { + msg.content = msg.content.map((content) => { + if (content.type === "text" || content.type === "reasoning") { + content.text = sanitizeSurrogates(content.text) + } + if (content.type === "tool-result") { + return sanitizeToolResultOutput(content) + } + return content + }) + } + return msg + } + }) + + // Anthropic rejects messages with empty content - filter out empty string messages + // and remove empty text/reasoning parts from array content + if (model.api.npm === "@ai-sdk/anthropic") { + msgs = msgs + .map((msg) => { + if (typeof msg.content === "string") { + if (msg.content === "") return undefined + return msg + } + if (!Array.isArray(msg.content)) return msg + const filtered = msg.content.filter((part) => { + if (part.type === "text") { + return part.text !== "" + } + if (part.type === "reasoning") { + return ( + part.text.trim().length > 0 || + part.providerOptions?.anthropic?.signature != null || + part.providerOptions?.anthropic?.redactedData != null + ) + } + return true + }) + if (filtered.length === 0) return undefined + return { ...msg, content: filtered } + }) + .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") + } + + // Bedrock specific transforms + if (model.api.npm === "@ai-sdk/amazon-bedrock") { + msgs = msgs + .map((msg) => { + if (typeof msg.content === "string") { + if (msg.content === "") return undefined + return msg + } + if (!Array.isArray(msg.content)) return msg + const filtered = msg.content.filter((part) => { + if (part.type === "text") { + return part.text !== "" + } + if (part.type === "reasoning") { + // Match what the SDK can replay before assigning cache points. Otherwise + // unsigned reasoning can leave an empty or cache-point-only message. + const metadata = part.providerOptions?.[model.providerID] ?? part.providerOptions?.bedrock + return metadata?.signature != null || metadata?.redactedContent != null || metadata?.redactedData != null + } + return true + }) + if (filtered.length === 0) return undefined + return { ...msg, content: filtered } + }) + .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") + } + + if (model.api.id.includes("claude")) { + const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_") + msgs = msgs.map((msg) => { + if (msg.role === "assistant" && Array.isArray(msg.content)) { + return { + ...msg, + content: msg.content.map((part) => { + if (part.type === "tool-call" || part.type === "tool-result") { + return { ...part, toolCallId: scrub(part.toolCallId) } + } + return part + }), + } + } + if (msg.role === "tool" && Array.isArray(msg.content)) { + return { + ...msg, + content: msg.content.map((part) => { + if (part.type === "tool-result") { + return { ...part, toolCallId: scrub(part.toolCallId) } + } + return part + }), + } + } + return msg + }) + } + + const modelID = model.api.id.toLowerCase() + if ( + model.providerID === "mistral" || + ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family)) + ) { + const scrub = (id: string) => { + return id + .replace(/[^a-zA-Z0-9]/g, "") // Remove non-alphanumeric characters + .substring(0, 9) // Take first 9 characters + .padEnd(9, "0") // Pad with zeros if less than 9 characters + } + const result: ModelMessage[] = [] + for (let i = 0; i < msgs.length; i++) { + const msg = msgs[i] + const nextMsg = msgs[i + 1] + + if (msg.role === "assistant" && Array.isArray(msg.content)) { + msg.content = msg.content.map((part) => { + if (part.type === "tool-call" || part.type === "tool-result") { + return { ...part, toolCallId: scrub(part.toolCallId) } + } + return part + }) + } + if (msg.role === "tool" && Array.isArray(msg.content)) { + msg.content = msg.content.map((part) => { + if (part.type === "tool-result") { + return { ...part, toolCallId: scrub(part.toolCallId) } + } + return part + }) + } + result.push(msg) + + // Fix message sequence: tool messages cannot be followed by user messages + if (msg.role === "tool" && nextMsg?.role === "user") { + result.push({ + role: "assistant", + content: [ + { + type: "text", + text: "Done.", + }, + ], + }) + } + } + return result + } + + // Deepseek requires all assistant messages to have reasoning on them + if (model.api.id.toLowerCase().includes("deepseek")) { + msgs = msgs.map((msg) => { + if (msg.role !== "assistant") return msg + if (Array.isArray(msg.content)) { + if (msg.content.some((part) => part.type === "reasoning")) return msg + return { ...msg, content: [...msg.content, { type: "reasoning", text: "" }] } + } + return { + ...msg, + content: [ + ...(msg.content ? [{ type: "text" as const, text: msg.content }] : []), + { type: "reasoning" as const, text: "" }, + ], + } + }) + } + + if ( + typeof model.capabilities.interleaved === "object" && + model.capabilities.interleaved.field && + model.api.npm !== "@openrouter/ai-sdk-provider" + ) { + const field = model.capabilities.interleaved.field + return msgs.map((msg) => { + if (msg.role === "assistant" && Array.isArray(msg.content)) { + const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") + const reasoningText = reasoningParts.map((part: any) => part.text).join("") + + // Filter out reasoning parts from content + const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") + + // Include reasoning_content | reasoning_details directly on the message for all assistant messages. + // Always set the field even when empty — some providers (e.g. DeepSeek) may return empty + // reasoning_content which still needs to be sent back in subsequent requests. + return { + ...msg, + content: filteredContent, + providerOptions: { + ...msg.providerOptions, + openaiCompatible: { + ...msg.providerOptions?.openaiCompatible, + [field]: reasoningText, + }, + }, + } + } + + return msg + }) + } + + return msgs +} + +function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { + const system = msgs.filter((msg) => msg.role === "system").slice(0, 2) + const final = msgs.filter((msg) => msg.role !== "system").slice(-2) + + const providerOptions = { + anthropic: { + cacheControl: { type: "ephemeral" }, + }, + openrouter: { + cacheControl: { type: "ephemeral" }, + }, + bedrock: { + cachePoint: { type: "default" }, + }, + openaiCompatible: { + cache_control: { type: "ephemeral" }, + }, + copilot: { + copilot_cache_control: { type: "ephemeral" }, + }, + alibaba: { + cacheControl: { type: "ephemeral" }, + }, + } + + for (const msg of unique([...system, ...final])) { + const useMessageLevelOptions = + model.providerID === "anthropic" || + model.providerID.includes("bedrock") || + model.api.npm === "@ai-sdk/amazon-bedrock" + const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 + + if (shouldUseContentOptions) { + const lastContent = msg.content[msg.content.length - 1] + if ( + lastContent && + typeof lastContent === "object" && + lastContent.type !== "tool-approval-request" && + lastContent.type !== "tool-approval-response" + ) { + lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions) + continue + } + } + + msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions) + } + + return msgs +} + +function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { + return msgs.map((msg) => { + if (msg.role !== "user" || !Array.isArray(msg.content)) return msg + + const filtered = msg.content.map((part) => { + if (part.type !== "file" && part.type !== "image") return part + + // Check for empty base64 image data + if (part.type === "image") { + const imageStr = String(part.image) + if (imageStr.startsWith("data:")) { + const match = imageStr.match(/^data:([^;]+);base64,(.*)$/) + if (match && (!match[2] || match[2].length === 0)) { + return { + type: "text" as const, + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", + } + } + } + } + + const mime = part.type === "image" ? String(part.image).split(";")[0].replace("data:", "") : part.mediaType + const filename = part.type === "file" ? part.filename : undefined + const modality = mimeToModality(mime) + if (!modality) return part + if (model.capabilities.input[modality]) return part + + const name = filename ? `"${filename}"` : modality + return { + type: "text" as const, + text: `ERROR: Cannot read ${name} (this model does not support ${modality} input). Inform the user.`, + } + }) + + return { ...msg, content: filtered } + }) +} + +function mapProviderOptions( + msgs: ModelMessage[], + transform: (options: Record | undefined) => Record | undefined, +) { + return msgs.map((msg) => { + if (!Array.isArray(msg.content)) return { ...msg, providerOptions: transform(msg.providerOptions) } + return { + ...msg, + providerOptions: transform(msg.providerOptions), + content: msg.content.map((part) => + part.type === "tool-approval-request" || part.type === "tool-approval-response" + ? part + : { ...part, providerOptions: transform(part.providerOptions) }, + ), + } as typeof msg + }) +} + +export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { + msgs = unsupportedParts(msgs, model) + msgs = normalizeMessages(msgs, model, options) + const usesAnthropicAutomaticCaching = + options.cacheControl !== undefined && + (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic") + if ( + (model.providerID === "anthropic" || + model.providerID === "google-vertex-anthropic" || + model.api.id.includes("anthropic") || + model.api.id.includes("claude") || + model.id.includes("anthropic") || + model.id.includes("claude") || + model.api.npm === "@ai-sdk/anthropic" || + model.api.npm === "@ai-sdk/alibaba") && + model.api.npm !== "@ai-sdk/gateway" && + !usesAnthropicAutomaticCaching + ) { + msgs = applyCaching(msgs, model) + } + + // Remap providerOptions keys from stored providerID to expected SDK key + const key = sdkKey(model.api.npm) + if (key && key !== model.providerID) { + const remap = (opts: Record | undefined) => { + if (!opts) return opts + if (!(model.providerID in opts)) return opts + const result = { ...opts } + result[key] = result[model.providerID] + delete result[model.providerID] + return result + } + + msgs = mapProviderOptions(msgs, remap) + } + + // Strip Responses item IDs before serialization, following Codex and keeping signed request bodies immutable. + if ( + options.store !== true && + key && + ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle", "@ai-sdk/github-copilot"].includes( + model.api.npm, + ) + ) { + msgs = mapProviderOptions(msgs, (options) => { + if (!options?.[key] || !("itemId" in options[key])) return options + const metadata = { ...options[key] } + delete metadata.itemId + return { ...options, [key]: metadata } + }) + } + + return msgs +} + +const GEMINI_MODELS_WITH_SAMPLING_DEFAULTS = [ + /gemini-2[.-]5(?:[.-]|$)/, + /gemini-3-(?:flash|pro)(?:[.-]|$)/, + /gemini-3[.-]1(?:[.-]|$)/, + /gemini-3[.-]5-flash(?!-lite)(?:[.-]|$)/, +] + +export function temperature(model: Provider.Model) { + const id = model.api.id.toLowerCase() + if (id.includes("north-mini-code")) return 1.0 + if (id.includes("claude")) return undefined + if (id.includes("gemini")) + return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 1.0 : undefined + if (id.includes("glm-4.6")) return 1.0 + if (id.includes("glm-4.7")) return 1.0 + if (id.includes("minimax-m2")) return 1.0 + if (id.includes("kimi-k2")) { + // kimi-k2-thinking & kimi-k2.5 && kimi-k2p5 && kimi-k2-5 + if (["thinking", "k2.", "k2p", "k2-5"].some((s) => id.includes(s))) { + return 1.0 + } + return 0.6 + } + return undefined +} + +export function topP(model: Provider.Model) { + const id = model.api.id.toLowerCase() + if (id.includes("gemini")) + return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 0.95 : undefined + if (["minimax-m2", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) { + return 0.95 + } + if ( + ["deepseek-v4-flash-0731", "deepseek-v4-flash:0731"].some((name) => id.includes(name)) || + (id.includes("deepseek-v4-flash") && (model.providerID === "deepseek" || model.providerID.startsWith("opencode"))) + ) { + return 0.95 + } + return undefined +} + +export function topK(model: Provider.Model) { + const id = model.api.id.toLowerCase() + if (id.includes("minimax-m2")) { + if (["m2.", "m25", "m21"].some((s) => id.includes(s))) return 40 + return 20 + } + if (id.includes("gemini")) + return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 64 : undefined + return undefined +} + +const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] +const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +const OPENAI_GPT5_1_EFFORTS = ["none", ...WIDELY_SUPPORTED_EFFORTS] +const OPENAI_GPT5_2_PLUS_EFFORTS = [...OPENAI_GPT5_1_EFFORTS, "xhigh"] +const OPENAI_GPT5_PRO_EFFORTS = ["high"] +const OPENAI_GPT5_PRO_2_PLUS_EFFORTS = ["medium", "high", "xhigh"] +const OPENAI_GPT5_CHAT_EFFORTS = ["medium"] +const OPENAI_GPT5_CODEX_XHIGH_EFFORTS = [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +const OPENAI_GPT5_CODEX_3_PLUS_EFFORTS = ["none", ...OPENAI_GPT5_CODEX_XHIGH_EFFORTS] + +// OpenAI rolled out the `none` reasoning_effort tier on this date (Responses API). +// Models released before it 400 on `reasoning_effort: "none"`, so we only expose +// it as a variant for models new enough to accept it. +const OPENAI_NONE_EFFORT_RELEASE_DATE = "2025-11-13" + +// OpenAI rolled out the `xhigh` reasoning_effort tier on this date. Same reasoning. +const OPENAI_XHIGH_EFFORT_RELEASE_DATE = "2025-12-04" + +// Matches members of the gpt-5 family across the id formats we encounter: +// "gpt-5", "gpt-5-nano", "gpt-5.4", "openai/gpt-5.4-codex". +// Anchored to start-of-string or "/" so it doesn't false-match "gpt-50" or "gpt-5o". +const GPT5_FAMILY_RE = /(?:^|\/)gpt-5(?:[.-]|$)/ +const GPT5_VERSION_RE = /(?:^|\/)gpt-5[.-](\d+)(?:[.-]|$)/ +const GPT5_PRO_RE = /(?:^|\/)gpt-5[.-]?pro(?:[.-]|$)/ +const GPT5_VERSIONED_PRO_RE = /(?:^|\/)gpt-5[.-]\d+[.-]pro(?:[.-]|$)/ + +function gpt5Version(apiId: string) { + return Number(GPT5_VERSION_RE.exec(apiId)?.[1]) || undefined +} + +function versionedGpt5ReasoningEfforts(apiId: string) { + if (GPT5_VERSIONED_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_2_PLUS_EFFORTS + const version = gpt5Version(apiId) + if (version === undefined) return undefined + if (version === 1) return OPENAI_GPT5_1_EFFORTS + return OPENAI_GPT5_2_PLUS_EFFORTS +} + +function gpt5CodexReasoningEfforts(apiId: string) { + if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("codex")) return undefined + const version = gpt5Version(apiId) + if (version !== undefined && version >= 3) return OPENAI_GPT5_CODEX_3_PLUS_EFFORTS + if (apiId.includes("codex-max") || (version !== undefined && version >= 2)) return OPENAI_GPT5_CODEX_XHIGH_EFFORTS + return WIDELY_SUPPORTED_EFFORTS +} + +function gpt5ChatReasoningEfforts(apiId: string) { + if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("-chat")) return undefined + return gpt5Version(apiId) === undefined ? [] : OPENAI_GPT5_CHAT_EFFORTS +} + +// Computes the reasoning_effort tiers an OpenAI (or OpenAI-compatible upstream +// routed through it, e.g. cf-ai-gateway) model exposes. Effort order: weakest +// to strongest. +function openaiReasoningEfforts(apiId: string, releaseDate: string) { + const id = apiId.toLowerCase() + if (id.includes("deep-research")) return ["medium"] + const chatEfforts = gpt5ChatReasoningEfforts(id) + if (chatEfforts) return chatEfforts + if (GPT5_PRO_RE.test(id)) return OPENAI_GPT5_PRO_EFFORTS + const codexEfforts = gpt5CodexReasoningEfforts(id) + if (codexEfforts) return codexEfforts + const versionedEfforts = versionedGpt5ReasoningEfforts(id) + // GPT-5.1 replaced GPT-5's `minimal` effort with `none`; GPT-5.2+ + // additionally accepts `xhigh`. Model pages list the supported subset. + if (versionedEfforts) return versionedEfforts + const efforts = [...WIDELY_SUPPORTED_EFFORTS] + if (GPT5_FAMILY_RE.test(id)) efforts.unshift("minimal") + if (releaseDate >= OPENAI_NONE_EFFORT_RELEASE_DATE) efforts.unshift("none") + if (releaseDate >= OPENAI_XHIGH_EFFORT_RELEASE_DATE) efforts.push("xhigh") + return efforts +} + +function openaiCompatibleReasoningEfforts(id: string) { + const apiId = id.toLowerCase() + const chatEfforts = gpt5ChatReasoningEfforts(apiId) + if (chatEfforts) return chatEfforts + if (GPT5_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_EFFORTS + return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS +} + +function anthropicUsesModernAdaptiveThinking(apiId: string) { + if (!apiId.toLowerCase().includes("claude-")) return false + // Covers family-first IDs such as claude-opus-4.7 and version-first IDs such as claude-4.7-opus. + // Limit minors to two digits so release dates in IDs such as claude-opus-4-20250514 are not versions. + const version = /claude-(?:[a-z]+-)?(\d+)(?:[.-](\d{1,2}))?(?:[.@-]|$)/i.exec(apiId) + if (!version) return true + const major = Number(version[1]) + const minor = Number(version[2] ?? 0) + return major > 4 || (major === 4 && minor >= 7) +} + +function anthropicOpus45(apiId: string) { + return ["opus-4-5", "opus-4.5"].some((value) => apiId.includes(value)) +} + +function anthropicAdaptiveEfforts(apiId: string): string[] | null { + if (anthropicUsesModernAdaptiveThinking(apiId)) { + return ["low", "medium", "high", "xhigh", "max"] + } + if ( + ["opus-4-6", "opus-4.6", "4-6-opus", "4.6-opus", "sonnet-4-6", "sonnet-4.6", "4-6-sonnet", "4.6-sonnet"].some((v) => + apiId.includes(v), + ) + ) { + return ["low", "medium", "high", "max"] + } + return null +} + +function anthropicOmitsThinking(apiId: string) { + return anthropicUsesModernAdaptiveThinking(apiId) +} + +// Default to binding controls for Claude 5.1+ as enforcement expands to later models. +// Mythos 5.1 explicitly does not run the conversation-prefix check. +// https://platform.claude.com/docs/en/build-with-claude/thinking#preserved-in-conversation +function anthropicBindsThinking(apiId: string) { + // Capture either family/version order, without reading release dates as minor versions. + const version = /claude-(?:([a-z]+)-)?(\d+)(?:[.-](\d{1,2}))?(?:-([a-z]+))?(?:[.@-]|$)/i.exec(apiId) + if (!version) return false + const major = Number(version[2]) + const minor = Number(version[3] ?? 0) + if (major === 5 && minor === 1 && (version[1] ?? version[4])?.toLowerCase() === "mythos") return false + return major > 5 || (major === 5 && minor >= 1) +} + +// Fable 5.1 binds each thinking signature to the system prompt, tool list, and +// messages above it, and rejects the request when any of that changes. opencode +// re-renders parts of that prefix between turns (system prompt, tools, compaction), +// so ask the API to drop the affected blocks instead of failing the request. +// Older model deployments may reject this field, even with thinking enabled. +// The patched AI SDK adds the thinking-binding-controls beta whenever it is set. +const ANTHROPIC_BLOCK_BINDING = { prefixMismatchBehavior: "drop_block" } + +function anthropicBlockBinding(model: Provider.Model, options: { [x: string]: any }) { + const sdk = sdkKey(model.api.npm) + const key = sdk === "bedrock" ? "reasoningConfig" : sdk === "anthropic" ? "thinking" : undefined + // Consume the OpenCode-only opt-out even on models outside the default scope. + if (key && options[key]?.blockBinding === false) { + const result = { ...options, [key]: { ...options[key] } } + delete result[key].blockBinding + if (Object.keys(result[key]).length === 0) delete result[key] + return result + } + + if (!anthropicBindsThinking(model.api.id)) return options + switch (model.api.npm) { + case "@ai-sdk/anthropic": + case "@ai-sdk/google-vertex/anthropic": { + const thinking = options.thinking ?? { type: "adaptive" } + if (thinking.type !== "adaptive" && thinking.type !== "enabled") return options + if (thinking.blockBinding !== undefined) return options + return { ...options, thinking: { ...thinking, blockBinding: ANTHROPIC_BLOCK_BINDING } } + } + case "@ai-sdk/amazon-bedrock": { + const reasoningConfig = options.reasoningConfig ?? { type: "adaptive" } + if (reasoningConfig.type !== "adaptive" && reasoningConfig.type !== "enabled") return options + if (reasoningConfig.blockBinding !== undefined) return options + return { ...options, reasoningConfig: { ...reasoningConfig, blockBinding: ANTHROPIC_BLOCK_BINDING } } + } + } + return options +} + +function googleThinkingLevelEfforts(apiId: string) { + const id = apiId.toLowerCase() + if (!id.includes("gemini-3")) return ["low", "high"] + if (id.includes("flash-image")) return ["minimal", "high"] + if (id.includes("pro-image")) return ["high"] + if (id.includes("flash")) return ["minimal", "low", "medium", "high"] + return ["low", "medium", "high"] +} + +function googleThinkingBudgetMax(apiId: string) { + const id = apiId.toLowerCase() + if (id.includes("2.5") && id.includes("pro") && !id.includes("flash")) return 32_768 + return 24_576 +} + +// SAP's Zod schema drops unknown top-level keys; reasoning controls survive +// only via `modelParams` (catchall), forwarded verbatim by the SAP SDKs. +function wrapInSapModelParams(variants: Record>): Record> { + return Object.fromEntries(Object.entries(variants).map(([k, v]) => [k, { modelParams: v }])) +} + +function googleThinkingVariants(model: Provider.Model): Record> { + const id = model.api.id.toLowerCase() + if (id.includes("2.5")) { + return { + high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } }, + max: { + thinkingConfig: { includeThoughts: true, thinkingBudget: googleThinkingBudgetMax(id) }, + }, + } + } + return Object.fromEntries( + googleThinkingLevelEfforts(id).map((effort) => [ + effort, + { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }, + ]), + ) +} + +export function variants(model: Provider.Model): Record> { + if (!model.capabilities.reasoning) return {} + + const id = model.id.toLowerCase() + const glm52 = ["glm-5.2", "glm-5-2", "glm-5p2"].some( + (name) => id.includes(name) || model.api.id.toLowerCase().includes(name), + ) + if ( + model.api.id.toLowerCase().includes("minimax-m3") && + ["@ai-sdk/anthropic", "@ai-sdk/openai-compatible"].includes(model.api.npm) + ) { + if (["nvidia", "lilac"].includes(model.providerID)) { + return { + none: { chat_template_kwargs: { thinking_mode: "disabled" } }, + thinking: { chat_template_kwargs: { thinking_mode: "enabled" } }, + } + } + return { + none: { thinking: { type: "disabled" } }, + thinking: { thinking: { type: "adaptive" } }, + } + } + const adaptiveThinkingOmitted = anthropicOmitsThinking(model.api.id) + const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id) + if (glm52 && model.api.npm === "@openrouter/ai-sdk-provider") { + // OpenRouter maps xhigh to GLM-5.2's native max effort. + return { + high: { reasoning: { effort: "high" } }, + xhigh: { reasoning: { effort: "xhigh" } }, + } + } + if (glm52 && model.api.npm === "@ai-sdk/openai-compatible") { + return { + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + } + } + if (glm52 && model.api.npm === "@ai-sdk/anthropic") { + return { + high: { effort: "high" }, + max: { effort: "max" }, + } + } + // Kimi's Anthropic-compatible transports implement adaptive thinking effort. + if (isKimiFamily(model) && ["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) { + return Object.fromEntries( + ["low", "medium", "high", "xhigh", "max"].map((effort) => [ + effort, + { thinking: { type: "adaptive", display: "summarized" }, effort }, + ]), + ) + } + if ( + id.includes("deepseek-chat") || + id.includes("deepseek-reasoner") || + id.includes("deepseek-r1") || + id.includes("deepseek-v3") || + id.includes("minimax") || + (id.includes("glm") && !glm52) || + id.includes("kimi") || + id.includes("k2p") || + id.includes("qwen") || + id.includes("big-pickle") + ) + return {} + + // see: https://docs.x.ai/docs/guides/reasoning#control-how-hard-the-model-thinks + if (id.includes("grok") && id.includes("grok-3-mini")) { + if (model.api.npm === "@openrouter/ai-sdk-provider") { + return { + low: { reasoning: { effort: "low" } }, + high: { reasoning: { effort: "high" } }, + } + } + return { + low: { reasoningEffort: "low" }, + high: { reasoningEffort: "high" }, + } + } + + switch (model.api.npm) { + case "@openrouter/ai-sdk-provider": + return Object.fromEntries( + (model.api.id.startsWith("openai/") || id.includes("gpt") + ? openaiCompatibleReasoningEfforts(model.api.id) + : WIDELY_SUPPORTED_EFFORTS + ).map((effort) => [effort, { reasoning: { effort } }]), + ) + + case "ai-gateway-provider": { + // Cloudflare AI Gateway routes every upstream through its OpenAI-compatible + // /v1/compat endpoint, so the body is always OAI-shaped. The gateway + // translates `reasoning_effort` to the upstream provider's native control + // (e.g. Anthropic thinking budgets) when needed. Variants therefore stay + // OAI-style for all upstreams, with an extended effort set for OpenAI + // models that support it. + if (model.api.id.startsWith("openai/")) { + const efforts = openaiReasoningEfforts(model.api.id, model.release_date) + return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }])) + } + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + } + + case "@ai-sdk/gateway": + if (model.api.id.includes("anthropic")) { + if (adaptiveEfforts) { + return Object.fromEntries( + adaptiveEfforts.map((effort) => [ + effort, + { + thinking: { + type: "adaptive", + // Newer adaptive-only models default `display` to "omitted", which + // returns empty thinking blocks. Force "summarized" so summaries + // survive (4.6/Sonnet 4.6 already default to "summarized"). + ...(adaptiveThinkingOmitted ? { display: "summarized" } : {}), + }, + effort, + }, + ]), + ) + } + return { + high: { + thinking: { + type: "enabled", + budgetTokens: 16000, + }, + }, + max: { + thinking: { + type: "enabled", + budgetTokens: 31999, + }, + }, + } + } + if (model.api.id.includes("google")) { + if (model.api.id.includes("2.5")) { + return { + high: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: 16000, + }, + }, + max: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: googleThinkingBudgetMax(model.api.id.toLowerCase()), + }, + }, + } + } + return Object.fromEntries( + ["low", "high"].map((effort) => [ + effort, + { + includeThoughts: true, + thinkingLevel: effort, + }, + ]), + ) + } + return Object.fromEntries( + openaiCompatibleReasoningEfforts(model.api.id).map((effort) => [effort, { reasoningEffort: effort }]), + ) + + case "@ai-sdk/github-copilot": + if (model.id.includes("gemini")) { + // currently github copilot only returns thinking + return {} + } + if (model.id.includes("claude")) { + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + } + const copilotEfforts = iife(() => { + if (id.includes("5.1-codex-max") || id.includes("5.2") || id.includes("5.3")) + return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] + const arr = [...WIDELY_SUPPORTED_EFFORTS] + if (id.includes("gpt-5") && model.release_date >= "2025-12-04") arr.push("xhigh") + return arr + }) + return Object.fromEntries( + copilotEfforts.map((effort) => [ + effort, + { + reasoningEffort: effort, + reasoningSummary: "auto", + include: INCLUDE_ENCRYPTED_REASONING, + }, + ]), + ) + + case "@ai-sdk/cerebras": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cerebras + case "@ai-sdk/togetherai": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/togetherai + case "@ai-sdk/xai": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/xai + case "@ai-sdk/deepinfra": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra + case "venice-ai-sdk-provider": + // https://docs.venice.ai/overview/guides/reasoning-models#reasoning-effort + case "@ai-sdk/openai-compatible": + if (model.api.id.toLowerCase().includes("north-mini-code")) { + return Object.fromEntries(["none", "high"].map((effort) => [effort, { reasoningEffort: effort }])) + } + const efforts = [...WIDELY_SUPPORTED_EFFORTS] + if (model.api.id.toLowerCase().includes("deepseek-v4")) { + efforts.push("max") + } + return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }])) + + case "@ai-sdk/azure": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure + if (id === "o1-mini") return {} + return Object.fromEntries( + openaiReasoningEfforts(id, model.release_date).map((effort) => [ + effort, + { + reasoningEffort: effort, + reasoningSummary: "auto", + include: INCLUDE_ENCRYPTED_REASONING, + }, + ]), + ) + case "@ai-sdk/amazon-bedrock/mantle": + case "@ai-sdk/openai": { + if (model.providerID === "meta") { + return Object.fromEntries( + OPENAI_EFFORTS.map((effort) => [ + effort, + { + reasoningEffort: effort, + reasoningSummary: "auto", + include: INCLUDE_ENCRYPTED_REASONING, + }, + ]), + ) + } + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai + const efforts = openaiReasoningEfforts(model.api.id, model.release_date) + return Object.fromEntries( + efforts.map((effort) => [ + effort, + { + reasoningEffort: effort, + reasoningSummary: "auto", + include: INCLUDE_ENCRYPTED_REASONING, + }, + ]), + ) + } + + case "@ai-sdk/anthropic": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic + case "@ai-sdk/google-vertex/anthropic": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider + if (adaptiveEfforts) { + let efforts = [...adaptiveEfforts] + if (model.providerID === "github-copilot") { + if (model.api.id.includes("opus-4.7")) { + efforts = ["medium"] + } + // Efforts currently supported are: low, medium, high + efforts = efforts.filter((v) => v !== "max" && v !== "xhigh") + } + return Object.fromEntries( + efforts.map((effort) => [ + effort, + { + thinking: { + type: "adaptive", + ...(adaptiveThinkingOmitted ? { display: "summarized" } : {}), + }, + effort, + }, + ]), + ) + } + + if (anthropicOpus45(model.api.id)) { + return Object.fromEntries( + WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, anthropicOpus45Effort(model, effort)]), + ) + } + + return { + high: { + thinking: { + type: "enabled", + budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)), + }, + }, + max: { + thinking: { + type: "enabled", + budgetTokens: Math.min(31_999, model.limit.output - 1), + }, + }, + } + + case "@ai-sdk/amazon-bedrock": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock + if (adaptiveEfforts) { + return Object.fromEntries( + adaptiveEfforts.map((effort) => [ + effort, + { + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: effort, + ...(adaptiveThinkingOmitted ? { display: "summarized" } : {}), + }, + }, + ]), + ) + } + // For Anthropic models on Bedrock, use reasoningConfig with budgetTokens + if (model.api.id.includes("anthropic")) { + return { + high: { + reasoningConfig: { + type: "enabled", + budgetTokens: 16000, + }, + }, + max: { + reasoningConfig: { + type: "enabled", + budgetTokens: 31999, + }, + }, + } + } + + // For Amazon Nova models, use reasoningConfig with maxReasoningEffort + return Object.fromEntries( + WIDELY_SUPPORTED_EFFORTS.map((effort) => [ + effort, + { + reasoningConfig: { + type: "enabled", + maxReasoningEffort: effort, + }, + }, + ]), + ) + + case "@ai-sdk/google-vertex": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex + case "@ai-sdk/google": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-generative-ai + return googleThinkingVariants(model) + + case "@ai-sdk/mistral": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/mistral + // https://docs.mistral.ai/capabilities/reasoning/adjustable + if (!model.capabilities.reasoning) return {} + // Only Mistral Small 4 and Medium 3.5 support reasoning + const MISTRAL_REASONING_IDS = [ + "mistral-small-2603", + "mistral-small-latest", + "mistral-medium-3.5", + "mistral-medium-2604", + ] + const mistralId = model.api.id.toLowerCase() + if (!MISTRAL_REASONING_IDS.some((id) => mistralId.includes(id))) return {} + return { + high: { reasoningEffort: "high" }, + } + + case "@ai-sdk/cohere": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cohere + return {} + + case "@ai-sdk/groq": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/groq + const groqEffort = ["none", ...WIDELY_SUPPORTED_EFFORTS] + return Object.fromEntries( + groqEffort.map((effort) => [ + effort, + { + reasoningEffort: effort, + }, + ]), + ) + + case "@ai-sdk/perplexity": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity + return {} + + case "@jerome-benoit/sap-ai-provider-v2": { + if (id.includes("anthropic")) { + if (adaptiveEfforts) { + // Bedrock adaptive splits `effort` out into `output_config` (vs Anthropic + // native which inlines it). Claude 4.7+ defaults `display` to "omitted". + return wrapInSapModelParams( + Object.fromEntries( + adaptiveEfforts.map((effort) => [ + effort, + { + thinking: { type: "adaptive", ...(adaptiveThinkingOmitted ? { display: "summarized" } : {}) }, + output_config: { effort }, + }, + ]), + ), + ) + } + return wrapInSapModelParams({ + high: { thinking: { type: "enabled", budget_tokens: 16000 } }, + max: { thinking: { type: "enabled", budget_tokens: 31999 } }, + }) + } + if (id.includes("gemini") && id.includes("2.5")) { + return wrapInSapModelParams(googleThinkingVariants(model)) + } + if (id.includes("gpt") || /\bo[1-9]/.test(id)) { + const efforts = openaiReasoningEfforts(id, model.release_date) + return wrapInSapModelParams(Object.fromEntries(efforts.map((effort) => [effort, { reasoning_effort: effort }]))) + } + return wrapInSapModelParams( + Object.fromEntries(["low", "medium", "high"].map((effort) => [effort, { reasoning_effort: effort }])), + ) + } + } + return {} +} + +export function options(input: { + model: Provider.Model + sessionID: string + providerOptions?: Record +}): Record { + const result: Record = {} + + if ( + input.model.api.npm === "@ai-sdk/google-vertex/anthropic" || + (!input.model.api.id.includes("claude") && input.model.api.npm === "@ai-sdk/anthropic") + ) { + result["toolStreaming"] = false + } + + // openai and providers using openai package should set store to false by default. + if ( + input.model.providerID === "openai" || + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/github-copilot" || + input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" || + input.model.api.npm === "@ai-sdk/xai" + ) { + result["store"] = false + } + + if (input.model.api.npm === "@ai-sdk/azure") { + result["store"] = false + } + + if (input.model.api.npm === "@openrouter/ai-sdk-provider" || input.model.api.npm === "@llmgateway/ai-sdk-provider") { + result["usage"] = { + include: true, + } + if (input.model.api.id.includes("gemini-3")) { + result["reasoning"] = { effort: "high" } + } + } + + if ( + input.model.providerID === "baseten" || + (input.model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(input.model.api.id)) + ) { + result["chat_template_args"] = { enable_thinking: true } + } + + if ( + ["zai", "zhipuai"].some((id) => input.model.providerID.includes(id)) && + input.model.api.npm === "@ai-sdk/openai-compatible" + ) { + result["thinking"] = { + type: "enabled", + clear_thinking: false, + } + } + + if (input.model.providerID === "meta" && input.model.api.npm === "@ai-sdk/openai") { + result["reasoningSummary"] = "auto" + result["include"] = INCLUDE_ENCRYPTED_REASONING + } + + if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") { + if (input.model.capabilities.reasoning) { + result["thinkingConfig"] = { + includeThoughts: true, + } + if (input.model.api.id.includes("gemini-3")) { + result["thinkingConfig"]["thinkingLevel"] = "high" + } + } + } + + const modelId = input.model.api.id.toLowerCase() + + // MiniMax's Anthropic interface defaults thinking off, unlike Chat Completions. + if (modelId.includes("minimax-m3") && input.model.api.npm === "@ai-sdk/anthropic") { + result["thinking"] = { type: "adaptive" } + } + + // Moonshot's Anthropic-compatible API uses adaptive effort rather than token budgets. + // Request summaries so thinking content survives replay on subsequent turns. + if ( + ["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(input.model.api.npm) && + isKimiFamily(input.model) && + input.model.capabilities.reasoning + ) { + result["thinking"] = { type: "adaptive", display: "summarized" } + result["effort"] = "high" + } + + // Enable thinking for reasoning models on alibaba-cn (DashScope). + // DashScope's OpenAI-compatible API requires `enable_thinking: true` in the request body + // to return reasoning_content. Without it, models like kimi-k2.5, qwen-plus, qwen3, qwq, + // deepseek-r1, etc. never output thinking/reasoning tokens. + // Note: kimi-k2-thinking is excluded as it returns reasoning_content by default. + if ( + input.model.providerID === "alibaba-cn" && + input.model.capabilities.reasoning && + input.model.api.npm === "@ai-sdk/openai-compatible" && + !modelId.includes("kimi-k2-thinking") + ) { + result["enable_thinking"] = true + } + + if (input.providerOptions?.setCacheKey !== false) { + if (input.model.api.npm === "@ai-sdk/deepinfra" || input.model.api.npm === "@ai-sdk/cerebras") { + result["prompt_cache_key"] = input.sessionID + } else if ( + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/azure" || + input.model.api.npm === "@ai-sdk/xai" || + input.model.api.npm === "@ai-sdk/mistral" || + input.model.api.npm === "venice-ai-sdk-provider" || + input.providerOptions?.setCacheKey === true + ) { + result["promptCacheKey"] = input.sessionID + } + } + + if (input.model.api.npm === "@ai-sdk/gateway") { + result["gateway"] = { caching: "auto" } + } + + // Any gpt version above 5.4 in combination with azure does not support reasoningEffort + // so we should return early here. + const [, gptMajorVersion, gptMinorVersion] = input.model.api.id.match(/gpt-(\d+)\.(\d+)/) ?? [] + const isGpt55OrNewer = Number(gptMajorVersion) > 5 || (Number(gptMajorVersion) === 5 && Number(gptMinorVersion) >= 5) + if (input.model.api.npm === "@ai-sdk/azure" && input.providerOptions?.useCompletionUrls) { + if (!isGpt55OrNewer) { + result["reasoningEffort"] = "medium" + } + return result + } + + if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) { + if (!input.model.api.id.includes("gpt-5-pro")) { + result["reasoningEffort"] = "medium" + if ( + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/azure" || + input.model.api.npm === "@ai-sdk/github-copilot" || + input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + ) { + result["reasoningSummary"] = "auto" + } + if (input.model.api.npm === "@ai-sdk/openai" || input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle") { + result["include"] = INCLUDE_ENCRYPTED_REASONING + } + } + + // Generic OpenAI-compatible APIs do not necessarily support OpenAI's verbosity parameter. + // Only enable the default for integrations known to implement it. + if ( + input.model.api.id.includes("gpt-5.") && + !input.model.api.id.includes("codex") && + !input.model.api.id.includes("-chat") && + (input.model.api.npm === "@ai-sdk/openai" || input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle") + ) { + result["textVerbosity"] = "low" + } + + if (input.model.providerID.startsWith("opencode") && input.providerOptions?.setCacheKey !== false) { + result["promptCacheKey"] = input.sessionID + result["include"] = INCLUDE_ENCRYPTED_REASONING + result["reasoningSummary"] = "auto" + } + } + + return result +} + +export function smallOptions(model: Provider.Model) { + const small = Object.values(model.variants ?? {})[0] ?? {} + if ( + model.providerID === "openai" || + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/github-copilot" || + model.api.npm === "@ai-sdk/xai" + ) { + const base = { store: false } + return mergeDeep(base, small) + } + if (model.providerID === "openrouter" || model.providerID === "llmgateway") { + if (Object.keys(small).length === 0 && model.api.id.includes("google")) { + return { reasoning: { enabled: false } } + } + } + + if (model.providerID === "venice") { + if (Object.keys(small).length > 0) return small + return { veniceParameters: { disableThinking: true } } + } + + return small +} + +// Maps model ID prefix to provider slug used in providerOptions. +// Example: "amazon/nova-2-lite" → "bedrock" +const SLUG_OVERRIDES: Record = { + amazon: "bedrock", +} + +export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { + const usesOpenAIReasoningGate = + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/azure" || + model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + const normalized = + usesOpenAIReasoningGate && + (model.capabilities.reasoning || options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) + ? { ...options, forceReasoning: true } + : anthropicBlockBinding(model, options) + + if (model.api.npm === "@ai-sdk/gateway") { + // Gateway providerOptions are split across two namespaces: + // - `gateway`: gateway-native routing/caching controls (order, only, byok, etc.) + // - ``: provider-specific model options (anthropic/openai/...) + // We keep `gateway` as-is and route every other top-level option under the + // model-derived upstream slug. + const i = model.api.id.indexOf("/") + const rawSlug = i > 0 ? model.api.id.slice(0, i) : undefined + const slug = rawSlug ? (SLUG_OVERRIDES[rawSlug] ?? rawSlug) : undefined + const gateway = normalized.gateway + const rest = Object.fromEntries(Object.entries(normalized).filter(([k]) => k !== "gateway")) + const has = Object.keys(rest).length > 0 + + const result: Record = {} + if (gateway !== undefined) result.gateway = gateway + + if (has) { + if (slug) { + // Route model-specific options under the provider slug + result[slug] = rest + } else if (gateway && typeof gateway === "object" && !Array.isArray(gateway)) { + result.gateway = { ...gateway, ...rest } + } else { + result.gateway = rest + } + } + + return result + } + + // AI SDK packages that resolve providerOptionsName by splitting the + // provider name on "." (e.g. "wafer.ai" -> "wafer") need the same + // logic here so the key we write matches the key they read. + // Other SDKs (xai, mistral, groq, cohere, etc.) use hardcoded keys + // like "xai" or "cohere" - applying .split(".")[0] would break those. + const usesDotSplitOptions = + model.api.npm === "@ai-sdk/openai-compatible" || + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/anthropic" + const key = sdkKey(model.api.npm) ?? (usesDotSplitOptions ? model.providerID.split(".")[0] : model.providerID) + // @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from + // providerOptions["openai"], but OpenAIResponsesLanguageModel checks + // "azure" first. Pass both so model options work on either code path. + if (model.api.npm === "@ai-sdk/azure") { + return { openai: normalized, azure: normalized } + } + return { [key]: normalized } +} + +export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number { + return Math.min(model.limit.output, outputTokenMax) || outputTokenMax +} + +type JsonRecord = Record + +function isPlainObject(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +// Mirrors Codex's Rust JSON schema compatibility lowering for OpenAI tool schemas. +function sanitizeOpenAISchema(value: unknown): unknown { + const types = ["string", "number", "boolean", "integer", "object", "array", "null"] + const compositionKeys = ["anyOf", "oneOf", "allOf"] + + // JSON Schema's boolean form (`true`/`false`) is unsupported by OpenAI tool schemas. + if (typeof value === "boolean") return { type: "string" } + if (Array.isArray(value)) return value.map(sanitizeOpenAISchema) + if (!isPlainObject(value)) return value + + const result: JsonRecord = {} + + if (typeof value.$ref === "string") result.$ref = value.$ref + if (typeof value.description === "string") result.description = value.description + if ("const" in value) result.enum = [value.const] + else if (Array.isArray(value.enum)) result.enum = value.enum + + if (isPlainObject(value.properties)) { + result.properties = Object.fromEntries( + Object.entries(value.properties).map(([key, item]) => [key, sanitizeOpenAISchema(item)]), + ) + } + + if (Array.isArray(value.required)) { + result.required = value.required.filter((item) => typeof item === "string") + } + + if ("items" in value) result.items = sanitizeOpenAISchema(value.items) + + if ("additionalProperties" in value) { + result.additionalProperties = + typeof value.additionalProperties === "boolean" + ? value.additionalProperties + : sanitizeOpenAISchema(value.additionalProperties) + } + + for (const key of compositionKeys) { + if (Array.isArray(value[key])) result[key] = value[key].map(sanitizeOpenAISchema) + } + + for (const key of ["$defs", "definitions"]) { + if (isPlainObject(value[key])) { + result[key] = Object.fromEntries( + Object.entries(value[key]).map(([name, item]) => [name, sanitizeOpenAISchema(item)]), + ) + } + } + + const schemaTypes = + typeof value.type === "string" + ? types.includes(value.type) + ? [value.type] + : [] + : Array.isArray(value.type) + ? value.type.filter((item) => typeof item === "string" && types.includes(item)) + : [] + + if (schemaTypes.length === 0 && (typeof result.$ref === "string" || compositionKeys.some((key) => key in result))) { + return result + } + + // MCP schemas may omit `type` while still using keywords that imply one. + // Keep the schema usable after unsupported keywords are dropped. + const inferredTypes = + schemaTypes.length > 0 + ? schemaTypes + : ["properties", "required", "additionalProperties"].some((key) => key in value) + ? ["object"] + : ["items", "prefixItems"].some((key) => key in value) + ? ["array"] + : "enum" in result || "format" in value + ? ["string"] + : ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"].some((key) => key in value) + ? ["number"] + : [] + + if (inferredTypes.length === 0) return {} + + result.type = inferredTypes.length === 1 ? inferredTypes[0] : inferredTypes + if (inferredTypes.includes("object") && !("properties" in result)) result.properties = {} + if (inferredTypes.includes("array") && !("items" in result)) result.items = { type: "string" } + return result +} + +export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 { + /* + if (["openai", "azure"].includes(providerID)) { + if (schema.type === "object" && schema.properties) { + for (const [key, value] of Object.entries(schema.properties)) { + if (schema.required?.includes(key)) continue + schema.properties[key] = { + anyOf: [ + value as JSONSchema.JSONSchema, + { + type: "null", + }, + ], + } + } + } + } + */ + + if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") { + schema = sanitizeOpenAISchema(schema) as JSONSchema7 + // Codex also applies lossy compaction above 4 KB; defer that until OpenCode needs the same schema budget. + } + + if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) { + const sanitizeMoonshot = (obj: unknown): unknown => { + if (obj === null || typeof obj !== "object") return obj + if (Array.isArray(obj)) return obj.map(sanitizeMoonshot) + // Moonshot expands $ref before validation and rejects sibling keywords like description on the same node. + if ("$ref" in obj && typeof obj.$ref === "string") return { $ref: obj.$ref } + const result = Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, sanitizeMoonshot(value)])) + // MFJS does not support tuple-style `items` arrays; it requires one schema object for all array items. + if (Array.isArray(result.items)) result.items = result.items[0] ?? {} + return result + } + + const sanitized = sanitizeMoonshot(schema) + if (typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized)) { + schema = sanitized + } + } + + // Convert integer enums to string enums for Google/Gemini + if (model.providerID === "google" || model.api.id.includes("gemini")) { + const isPlainObject = (node: unknown): node is Record => + typeof node === "object" && node !== null && !Array.isArray(node) + const hasCombiner = (node: unknown) => + isPlainObject(node) && (Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf)) + const hasSchemaIntent = (node: unknown) => { + if (!isPlainObject(node)) return false + if (hasCombiner(node)) return true + return [ + "type", + "properties", + "items", + "prefixItems", + "enum", + "const", + "$ref", + "additionalProperties", + "patternProperties", + "required", + "not", + "if", + "then", + "else", + ].some((key) => key in node) + } + + const sanitizeGemini = (obj: any): any => { + if (obj === null || typeof obj !== "object") { + return obj + } + + if (Array.isArray(obj)) { + return obj.map(sanitizeGemini) + } + + const result: any = {} + for (const [key, value] of Object.entries(obj)) { + if (key === "enum" && Array.isArray(value)) { + // Convert all enum values to strings + result[key] = value.map((v) => String(v)) + // If we have integer type with enum, change type to string + if (result.type === "integer" || result.type === "number") { + result.type = "string" + } + } else if (typeof value === "object" && value !== null) { + result[key] = sanitizeGemini(value) + } else { + result[key] = value + } + } + + // Gemini requires a single `type`, not a JSON Schema type array such as + // `["number","string"]` (emitted by some MCP servers). Plain `@ai-sdk/google` + // rewrites these into an `anyOf` of single-type schemas, but OpenAI-compatible + // transports (e.g. GitHub Copilot proxying to Gemini) forward them verbatim + // and the backend rejects the array form. Mirror the SDK: split non-null + // types into `anyOf`, and lift `null` into `nullable`. + if (Array.isArray(result.type)) { + const hasNull = result.type.includes("null") + const nonNull = result.type.filter((entry: unknown) => entry !== "null") + if (nonNull.length === 0) { + result.type = "null" + } else { + delete result.type + result.anyOf = nonNull.map((entry: unknown) => ({ type: entry })) + if (hasNull) result.nullable = true + } + } + + // Filter required array to only include fields that exist in properties + if (result.type === "object" && result.properties && Array.isArray(result.required)) { + result.required = result.required.filter((field: any) => field in result.properties) + } + + if (result.type === "array" && !hasCombiner(result)) { + if (result.items == null) { + result.items = {} + } + // Ensure items has a type only when it's still schema-empty. + if (isPlainObject(result.items) && !hasSchemaIntent(result.items)) { + result.items.type = "string" + } + } + + // Remove properties/required from non-object types (Gemini rejects these) + if (result.type && result.type !== "object" && !hasCombiner(result)) { + delete result.properties + delete result.required + } + + return result + } + + schema = sanitizeGemini(schema) + } + + return schema +} + +export function reasoningVariants(model: ModelsDev.Model, target: Provider.Model): Provider.Model["variants"] { + const options = model.reasoning_options + if (options === undefined) return + if (options.length === 0) return {} + + const effort = options.find((option) => option.type === "effort") + if (effort) return effortVariants(target, effort.values) + + const toggle = options.some((option) => option.type === "toggle") + const budget = options.find((option) => option.type === "budget_tokens") + if (!budget) return toggle ? nonEmptyVariants(reasoningToggle(target)) : undefined + + return nonEmptyVariants({ + ...(toggle ? reasoningToggle(target) : {}), + ...budgetVariants(target, budget.min, budget.max), + }) +} + +function effortVariants(model: Provider.Model, values: readonly unknown[]) { + return Object.fromEntries( + values.flatMap((value) => { + const id = (() => { + if (value === null) return "none" + if (typeof value === "string") return value + })() + if (id === undefined) return [] + const settings = reasoningEffort(model, id) + return settings ? [[id, settings]] : [] + }), + ) +} + +function budgetVariants(model: Provider.Model, min?: number, max?: number) { + const maximum = Math.min(max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1) + if (maximum <= 0) return {} + const high = Math.min(Math.max(min ?? 0, Math.floor((maximum + 1) / 2)), maximum) + return Object.fromEntries( + [ + { id: "high", budget: high }, + { id: "max", budget: maximum }, + ].flatMap((item) => { + const settings = reasoningBudget(model, item.budget) + return settings ? [[item.id, settings]] : [] + }), + ) +} + +function nonEmptyVariants(variants: NonNullable): Provider.Model["variants"] { + return Object.keys(variants).length > 0 ? variants : undefined +} + +function reasoningToggle(model: Provider.Model): NonNullable { + if (model.api.npm === "@ai-sdk/alibaba") + return { + none: { enableThinking: false }, + high: { enableThinking: true }, + } + if (model.api.npm === "@ai-sdk/cohere") + return { + none: { thinking: { type: "disabled" } }, + high: { thinking: { type: "enabled" } }, + } + return {} +} + +function reasoningEffort(model: Provider.Model, effort: string) { + switch (model.api.npm) { + case "@openrouter/ai-sdk-provider": + return { reasoning: { effort } } + case "@ai-sdk/anthropic": + case "@ai-sdk/google-vertex/anthropic": + return anthropicEffort(model, effort) ?? { effort } + case "@ai-sdk/google": + case "@ai-sdk/google-vertex": + return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } + case "@ai-sdk/amazon-bedrock": + if (anthropicAdaptiveEfforts(model.api.id)) + return { + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: effort, + ...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}), + }, + } + if (anthropicOpus45(model.api.id)) + return { + reasoningConfig: { + type: "enabled", + budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)), + maxReasoningEffort: effort, + }, + } + if (model.api.id.includes("anthropic")) return + return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } } + case "@ai-sdk/gateway": + if (model.id.includes("anthropic")) return { thinking: { type: "adaptive", display: "summarized" }, effort } + if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } + return { reasoningEffort: effort } + case "@ai-sdk/github-copilot": + // OAuth discovery replaces these with variants from Copilot's /models capabilities. + if (model.id.includes("gemini")) return + if (model.id.includes("claude")) return { reasoningEffort: effort } + return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } + case "@ai-sdk/openai": + case "@ai-sdk/amazon-bedrock/mantle": + return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } + case "@ai-sdk/azure": + return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } + case "@jerome-benoit/sap-ai-provider-v2": + if (model.id.includes("anthropic")) + return { modelParams: { thinking: { type: "adaptive", display: "summarized" }, output_config: { effort } } } + return { modelParams: { reasoning_effort: effort } } + case "@ai-sdk/openai-compatible": + case "@ai-sdk/xai": + case "@ai-sdk/mistral": + case "@ai-sdk/groq": + case "@ai-sdk/cerebras": + case "@ai-sdk/deepinfra": + case "@ai-sdk/togetherai": + case "venice-ai-sdk-provider": + case "ai-gateway-provider": + case "merge-gateway-ai-sdk-provider": + return { reasoningEffort: effort } + case "gitlab-ai-provider": + if (model.family?.startsWith("gpt")) return { reasoningEffort: effort } + if (model.family?.startsWith("claude")) return { thinking: { type: "adaptive", effort } } + return + case "@ai-sdk/cohere": + case "@ai-sdk/perplexity": + case "@ai-sdk/vercel": + case "@ai-sdk/alibaba": + return + } +} + +function anthropicEffort(model: Provider.Model, effort: string) { + if (anthropicOpus45(model.api.id)) return anthropicOpus45Effort(model, effort) + // Kimi defaults to omitting adaptive thinking text unless summarized display is requested. + if (isKimiFamily(model)) return { thinking: { type: "adaptive", display: "summarized" }, effort } + if (!anthropicAdaptiveEfforts(model.api.id)) return + return { + thinking: { + type: "adaptive", + ...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}), + }, + effort, + } +} + +function anthropicOpus45Effort(model: Provider.Model, effort: string) { + return { + thinking: { + type: "enabled", + budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)), + }, + effort, + } +} + +function reasoningBudget(model: Provider.Model, budget: number) { + switch (model.api.npm) { + case "@openrouter/ai-sdk-provider": + return { reasoning: { max_tokens: budget } } + case "@ai-sdk/anthropic": + case "@ai-sdk/google-vertex/anthropic": + return { thinking: { type: "enabled", budgetTokens: budget } } + case "@ai-sdk/google": + case "@ai-sdk/google-vertex": + return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } + case "@ai-sdk/amazon-bedrock": + return { reasoningConfig: { type: "enabled", budgetTokens: budget } } + case "@ai-sdk/gateway": + if (model.id.includes("anthropic")) return { thinking: { type: "enabled", budgetTokens: budget } } + if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } + return + case "@ai-sdk/cohere": + return { thinking: { type: "enabled", tokenBudget: budget } } + case "@ai-sdk/alibaba": + return { enableThinking: true, thinkingBudget: budget } + case "@jerome-benoit/sap-ai-provider-v2": + if (model.id.includes("anthropic")) + return { modelParams: { thinking: { type: "enabled", budget_tokens: budget } } } + if (model.id.includes("gemini")) + return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } } + return + case "@ai-sdk/amazon-bedrock/mantle": + case "@ai-sdk/azure": + case "@ai-sdk/cerebras": + case "@ai-sdk/deepinfra": + case "@ai-sdk/github-copilot": + case "@ai-sdk/groq": + case "@ai-sdk/mistral": + case "@ai-sdk/openai": + case "@ai-sdk/openai-compatible": + case "@ai-sdk/perplexity": + case "@ai-sdk/togetherai": + case "@ai-sdk/vercel": + case "@ai-sdk/xai": + case "ai-gateway-provider": + case "gitlab-ai-provider": + case "venice-ai-sdk-provider": + return + } +} + +export * as ProviderTransform from "./transform" diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8afc141072d535c9d1aa4594094b4df75857cf83 --- /dev/null +++ b/packages/opencode/src/question/index.ts @@ -0,0 +1,161 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Deferred, Effect, Layer, Schema, Context } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { SessionID } from "@/session/schema" +import { QuestionID } from "./schema" +import { EventV2Bridge } from "@/event-v2-bridge" +import { QuestionV1 } from "@opencode-ai/schema/question-v1" + +export const Option = QuestionV1.Option +export type Option = typeof Option.Type +export const Info = QuestionV1.Info +export type Info = typeof Info.Type +export const Prompt = QuestionV1.Prompt +export type Prompt = typeof Prompt.Type +export const Tool = QuestionV1.Tool +export type Tool = typeof Tool.Type +export const Request = QuestionV1.Request +export type Request = typeof Request.Type +export const Answer = QuestionV1.Answer +export type Answer = typeof Answer.Type +export const Reply = QuestionV1.Reply +export type Reply = typeof Reply.Type +export const Replied = QuestionV1.Replied +export const Rejected = QuestionV1.Rejected +export const Event = QuestionV1.Event + +export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { + override get message() { + return "The user dismissed this question" + } +} + +export class NotFoundError extends Schema.TaggedErrorClass()("Question.NotFoundError", { + requestID: QuestionID, +}) {} + +interface PendingEntry { + info: Request + deferred: Deferred.Deferred, RejectedError> +} + +interface State { + pending: Map +} + +// Service + +export interface Interface { + readonly ask: (input: { + sessionID: SessionID + questions: ReadonlyArray + tool?: Tool + }) => Effect.Effect, RejectedError> + readonly reply: (input: { + requestID: QuestionID + answers: ReadonlyArray + }) => Effect.Effect + readonly reject: (requestID: QuestionID) => Effect.Effect + readonly list: () => Effect.Effect> +} + +export class Service extends Context.Service()("@opencode/Question") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2Bridge.Service + const state = yield* InstanceState.make( + Effect.fn("Question.state")(function* () { + const state = { + pending: new Map(), + } + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const item of state.pending.values()) { + yield* Deferred.fail(item.deferred, new RejectedError()) + } + state.pending.clear() + }), + ) + + return state + }), + ) + + const ask = Effect.fn("Question.ask")(function* (input: { + sessionID: SessionID + questions: ReadonlyArray + tool?: Tool + }) { + const pending = (yield* InstanceState.get(state)).pending + const id = QuestionID.ascending() + yield* Effect.logInfo("asking", { id, questions: input.questions.length }) + + const deferred = yield* Deferred.make, RejectedError>() + const info: Request = { + id, + sessionID: input.sessionID, + questions: input.questions, + tool: input.tool, + } + pending.set(id, { info, deferred }) + yield* events.publish(Event.Asked, info) + + return yield* Effect.ensuring( + Deferred.await(deferred), + Effect.sync(() => { + pending.delete(id) + }), + ) + }) + + const reply = Effect.fn("Question.reply")(function* (input: { + requestID: QuestionID + answers: ReadonlyArray + }) { + const pending = (yield* InstanceState.get(state)).pending + const existing = pending.get(input.requestID) + if (!existing) { + yield* Effect.logWarning("reply for unknown request", { requestID: input.requestID }) + return yield* new NotFoundError({ requestID: input.requestID }) + } + pending.delete(input.requestID) + yield* Effect.logInfo("replied", { requestID: input.requestID, answers: input.answers }) + yield* events.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + answers: input.answers.map((a) => [...a]), + }) + yield* Deferred.succeed(existing.deferred, input.answers) + }) + + const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) { + const pending = (yield* InstanceState.get(state)).pending + const existing = pending.get(requestID) + if (!existing) { + yield* Effect.logWarning("reject for unknown request", { requestID }) + return yield* new NotFoundError({ requestID }) + } + pending.delete(requestID) + yield* Effect.logInfo("rejected", { requestID }) + yield* events.publish(Event.Rejected, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + }) + yield* Deferred.fail(existing.deferred, new RejectedError()) + }) + + const list = Effect.fn("Question.list")(function* () { + const pending = (yield* InstanceState.get(state)).pending + return Array.from(pending.values(), (x) => x.info) + }) + + return Service.of({ ask, reply, reject, list }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) + +export * as Question from "." diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed7f1edee7fcc9f1235709f48b499fdfa6581125 --- /dev/null +++ b/packages/opencode/src/question/schema.ts @@ -0,0 +1,4 @@ +import { QuestionV1 } from "@opencode-ai/schema/question-v1" + +export const QuestionID = QuestionV1.ID +export type QuestionID = typeof QuestionID.Type diff --git a/packages/opencode/src/server/auth.ts b/packages/opencode/src/server/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..9630ddbe20ed28944037de56363c672666931964 --- /dev/null +++ b/packages/opencode/src/server/auth.ts @@ -0,0 +1,48 @@ +export * as ServerAuth from "./auth" + +import { ConfigService } from "@/effect/config-service" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Config as EffectConfig, Context, Option, Redacted } from "effect" + +export type Credentials = { + password?: string + username?: string +} + +export type DecodedCredentials = { + readonly username: string + readonly password: Redacted.Redacted +} + +export class Config extends ConfigService.Service()("@opencode/ServerAuthConfig", { + password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option), + username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")), +}) {} + +export type Info = Context.Service.Shape + +export function required(config: Info) { + return Option.isSome(config.password) && config.password.value !== "" +} + +export function authorized(credentials: DecodedCredentials, config: Info) { + return ( + Option.isSome(config.password) && + credentials.username === config.username && + Redacted.value(credentials.password) === config.password.value + ) +} + +export function header(credentials?: Credentials) { + const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + + const username = credentials?.username ?? Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +export function headers(credentials?: Credentials) { + const authorization = header(credentials) + if (!authorization) return undefined + return { Authorization: authorization } +} diff --git a/packages/opencode/src/server/event.ts b/packages/opencode/src/server/event.ts new file mode 100644 index 0000000000000000000000000000000000000000..9bb8a8c3b04f63b3da73635bf2a837b47a4be0d7 --- /dev/null +++ b/packages/opencode/src/server/event.ts @@ -0,0 +1,10 @@ +import { Schema } from "effect" +import { ServerEvent } from "@opencode-ai/schema/server-event" + +export const Event = ServerEvent + +export const InstanceDisposed = Schema.Struct({ + id: Schema.String, + type: Schema.Literal("server.instance.disposed"), + properties: Schema.Struct({ directory: Schema.String }), +}).annotate({ identifier: "Event.server.instance.disposed" }) diff --git a/packages/opencode/src/server/global-lifecycle.ts b/packages/opencode/src/server/global-lifecycle.ts new file mode 100644 index 0000000000000000000000000000000000000000..12b7687bfe08487423f51c0edf0b0e1d8188419a --- /dev/null +++ b/packages/opencode/src/server/global-lifecycle.ts @@ -0,0 +1,28 @@ +import { GlobalBus } from "@/bus/global" +import { InstanceStore } from "@/project/instance-store" +import { Effect } from "effect" +import { Event } from "./event" + +export const emitGlobalDisposed = Effect.sync(() => + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.Disposed.type, + properties: {}, + }, + }), +) + +export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn("Server.disposeAllInstancesAndEmitGlobalDisposed")( + function* (options?: { swallowErrors?: boolean }) { + const store = yield* InstanceStore.Service + yield* Effect.gen(function* () { + yield* options?.swallowErrors + ? store.disposeAll().pipe(Effect.catchCause((cause) => Effect.logWarning("global disposal failed", { cause }))) + : store.disposeAll() + yield* emitGlobalDisposed + }).pipe(Effect.uninterruptible) + }, +) + +export * as GlobalLifecycle from "./global-lifecycle" diff --git a/packages/opencode/src/server/init-projectors.ts b/packages/opencode/src/server/init-projectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..86b66b256f97dcd0d46a648a9ed9e0e164666cfb --- /dev/null +++ b/packages/opencode/src/server/init-projectors.ts @@ -0,0 +1,3 @@ +import { initProjectors } from "./projectors" + +initProjectors() diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts new file mode 100644 index 0000000000000000000000000000000000000000..0dd673c7b42ef2b6039940a2143578db0e97c9e4 --- /dev/null +++ b/packages/opencode/src/server/mdns.ts @@ -0,0 +1,47 @@ +import { Bonjour } from "bonjour-service" + +let bonjour: Bonjour | undefined +let currentPort: number | undefined + +export function publish(port: number, domain?: string) { + if (currentPort === port) return + if (bonjour) unpublish() + + try { + const host = domain ?? "opencode.local" + const name = `opencode-${port}` + bonjour = new Bonjour() + const service = bonjour.publish({ + name, + type: "http", + host, + port, + txt: { path: "/" }, + }) + + service.on("error", () => {}) + + currentPort = port + } catch { + if (bonjour) { + try { + bonjour.destroy() + } catch {} + } + bonjour = undefined + currentPort = undefined + } +} + +export function unpublish() { + if (bonjour) { + try { + bonjour.unpublishAll() + bonjour.destroy() + } catch {} + bonjour = undefined + currentPort = undefined + } +} + +export * as MDNS from "./mdns" diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9142beab2a962191cdcd67281cbb8114224f66f --- /dev/null +++ b/packages/opencode/src/server/projectors.ts @@ -0,0 +1 @@ +export function initProjectors() {} diff --git a/packages/opencode/src/server/proxy-util.ts b/packages/opencode/src/server/proxy-util.ts new file mode 100644 index 0000000000000000000000000000000000000000..5107f4759a881c23d461caf4616dd1ec2237f47a --- /dev/null +++ b/packages/opencode/src/server/proxy-util.ts @@ -0,0 +1,48 @@ +const hop = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host", +]) + +function sanitize(out: Headers) { + for (const key of hop) out.delete(key) + out.delete("accept-encoding") + out.delete("x-opencode-directory") + out.delete("x-opencode-workspace") +} + +export function headers(input: Request | HeadersInit | Record, extra?: HeadersInit) { + const raw = input instanceof Request ? input.headers : input + const out = new Headers(raw instanceof Headers ? raw : Object.entries(raw as Record)) + sanitize(out) + if (!extra) return out + for (const [key, value] of new Headers(extra).entries()) { + out.set(key, value) + } + return out +} + +export function websocketProtocols(input: Request | Record) { + const value = input instanceof Request ? input.headers.get("sec-websocket-protocol") : input["sec-websocket-protocol"] + if (!value) return [] + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean) +} + +export function websocketTargetURL(url: string | URL) { + const next = new URL(url) + if (next.protocol === "http:") next.protocol = "ws:" + if (next.protocol === "https:") next.protocol = "wss:" + return next.toString() +} + +export * as ProxyUtil from "./proxy-util" diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..440b992c155774c9611dd01b3de2f400a522e71b --- /dev/null +++ b/packages/opencode/src/server/server.ts @@ -0,0 +1,226 @@ +import "./init-projectors" + +import { NodeHttpServer } from "@effect/platform-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" +import { HttpRouter, HttpServer } from "effect/unstable/http" +import { OpenApi } from "effect/unstable/httpapi" +import { createServer } from "node:http" +import { MDNS } from "./mdns" +import { HttpApiApp } from "./routes/instance/httpapi/server" +import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" +import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker" +import { PublicApi } from "./routes/instance/httpapi/public" +import type { CorsOptions } from "@opencode-ai/server/cors" +import { lazy } from "@/util/lazy" + +// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 +globalThis.AI_SDK_LOG_WARNINGS = false + +export type Listener = { + hostname: string + port: number + url: URL + stop: (close?: boolean) => Promise +} + +type ServerApp = { + fetch(request: Request): Response | Promise + request(input: string | URL | Request, init?: RequestInit): Response | Promise +} + +type ListenOptions = CorsOptions & { + port: number + hostname: string + mdns?: boolean + mdnsDomain?: string +} +type ListenerState = { + scope: Scope.Scope + server: Context.Service.Shape + http: ListenerServer + websockets: WebSocketTracker.Interface +} +type EffectListener = Omit & { + stop: (close?: boolean) => Effect.Effect +} + +interface ListenerServer { + readonly closeAll: Effect.Effect +} + +class ListenerServerService extends Context.Service()( + "@opencode/ListenerServer", +) {} + +export const Default = lazy(() => { + const handler = HttpApiApp.webHandler().handler + const app: ServerApp = { + fetch: (request: Request) => handler(request, HttpApiApp.context), + request(input, init) { + return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) + }, + } + return { app } +}) + +export async function openapi() { + return OpenApi.fromApi(PublicApi) +} + +export let url: URL | undefined + +export async function listen(opts: ListenOptions): Promise { + const listener = await Effect.runPromise(listenEffect(opts)) + return { + hostname: listener.hostname, + port: listener.port, + url: listener.url, + stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined), + } +} + +const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")( + function* (opts: ListenOptions) { + const state = yield* startWithPortFallback(opts) + const address = yield* tcpAddress(state) + const listenerUrl = makeURL(opts.hostname, address.port) + const unpublishMdns = yield* setupMdns(opts, address.port, state.scope) + url = listenerUrl + + return { + hostname: opts.hostname, + port: address.port, + url: listenerUrl, + stop: yield* makeStop(state, unpublishMdns, listenerUrl), + } + }, +) + +function listenerLayer(opts: ListenOptions, port: number) { + return HttpRouter.serve(HttpApiApp.createRoutes(opts), { + middleware: disposeMiddleware, + disableLogger: true, + disableListenLog: true, + }).pipe( + Layer.provideMerge(AppNodeBuilder.build(WebSocketTracker.node)), + Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })), + // Install a fresh `ConfigProvider` per listener so `Config.string(...)` + // reads reflect the current `process.env`. Effect's default + // `ConfigProvider` snapshots `process.env` on first read and caches the + // result on a module-singleton Reference; without overriding it here, + // every later `Server.listen()` keeps observing that initial snapshot. + Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv())), + ) +} + +function startWithPortFallback(opts: ListenOptions) { + if (opts.port !== 0) return startListener(opts, opts.port) + // Match the legacy listener port-resolution behavior: explicit `0` prefers + // 4096 first, then any free port. + return startListener(opts, 4096).pipe(Effect.catch(() => startListener(opts, 0))) +} + +function startListener(opts: ListenOptions, port: number) { + const scope = Scope.makeUnsafe() + return Layer.buildWithMemoMap(listenerLayer(opts, port), Layer.makeMemoMapUnsafe(), scope).pipe( + Effect.provide(HttpApiApp.context), + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + Effect.map( + (ctx): ListenerState => ({ + scope, + server: Context.get(ctx, HttpServer.HttpServer), + http: Context.get(ctx, ListenerServerService), + websockets: Context.get(ctx, WebSocketTracker.Service), + }), + ), + ) +} + +function tcpAddress(state: ListenerState) { + return Effect.gen(function* () { + if (state.server.address._tag === "TcpAddress") return state.server.address + yield* Scope.close(state.scope, Exit.void).pipe(Effect.ignore) + return yield* Effect.die(new Error(`Unexpected HttpServer address tag: ${state.server.address._tag}`)) + }) +} + +function makeURL(hostname: string, port: number) { + const result = new URL("http://localhost") + result.hostname = hostname + result.port = String(port) + return result +} + +function setupMdns(opts: ListenOptions, port: number, scope: Scope.Scope) { + return Effect.gen(function* () { + const publish = + opts.mdns && port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" + if (publish) { + const unpublish = yield* Effect.cached(Effect.sync(() => MDNS.unpublish())) + yield* Effect.sync(() => MDNS.publish(port, opts.mdnsDomain)) + yield* Scope.addFinalizer(scope, unpublish) + return unpublish + } + if (opts.mdns) { + yield* Effect.logWarning("mDNS enabled but hostname is loopback; skipping mDNS publish") + } + return Effect.void + }) +} + +function makeStop(state: ListenerState, unpublishMdns: Effect.Effect, listenerUrl: URL) { + return Effect.gen(function* () { + const forceCloseOnce = yield* Effect.cached(forceClose(state).pipe(Effect.ignore)) + const closeScopeOnce = yield* Effect.cached( + Scope.close(state.scope, Exit.void).pipe( + Effect.ignore, + Effect.ensuring( + Effect.sync(() => { + if (url === listenerUrl) url = undefined + }), + ), + ), + ) + + return (close?: boolean) => + Effect.gen(function* () { + yield* unpublishMdns + if (close) yield* forceCloseOnce + yield* closeScopeOnce + }) + }) +} + +function forceClose(state: ListenerState) { + return Effect.all([state.http.closeAll, state.websockets.closeAll], { concurrency: "unbounded", discard: true }) +} + +function serverLayer(opts: { port: number; hostname: string }) { + const server = createServer() + const serverRef = { closeStarted: false, forceStop: false } + const close = server.close.bind(server) + // Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by + // force-closing active HTTP sockets when its finalizer calls server.close(). + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Node's overloads don't preserve a monkey-patched method assignment. + server.close = ((callback?: Parameters[0]) => { + serverRef.closeStarted = true + const result = close(callback) + if (serverRef.forceStop) server.closeAllConnections() + return result + }) as typeof server.close + + return Layer.mergeAll( + NodeHttpServer.layer(() => server, { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" }), + Layer.succeed(ListenerServerService)( + ListenerServerService.of({ + closeAll: Effect.sync(() => { + serverRef.forceStop = true + if (serverRef.closeStarted) server.closeAllConnections() + }), + }), + ), + ) +} + +export * as Server from "./server" diff --git a/packages/opencode/src/server/tui-event.ts b/packages/opencode/src/server/tui-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..3fb3576db009668406a2c91352a53bf524d6ff93 --- /dev/null +++ b/packages/opencode/src/server/tui-event.ts @@ -0,0 +1 @@ +export { TuiEvent } from "@opencode-ai/schema/tui-event" diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts new file mode 100644 index 0000000000000000000000000000000000000000..b56a67610f82298a0ca6345bb82d295aa6fb0526 --- /dev/null +++ b/packages/opencode/src/skill/discovery.ts @@ -0,0 +1,140 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient, path } from "@opencode-ai/core/effect/app-node-platform" +import { NodePath } from "@effect/platform-node" +import { Effect, Layer, Path, Schema, Context } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { withTransientReadRetry } from "@/util/effect-http-client" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" + +const skillConcurrency = 4 +const fileConcurrency = 8 + +class IndexSkill extends Schema.Class("IndexSkill")({ + name: Schema.String, + files: Schema.Array(Schema.String), + version: Schema.optional(Schema.String), +}) {} + +class Index extends Schema.Class("Index")({ + skills: Schema.Array(IndexSkill), +}) {} + +export interface Interface { + readonly pull: (url: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/SkillDiscovery") {} + +const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const path = yield* Path.Path + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) + const cache = path.join(Global.Path.cache, "skills") + + const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) { + if (yield* fs.exists(dest).pipe(Effect.orDie)) return true + + return yield* HttpClientRequest.get(url).pipe( + http.execute, + Effect.flatMap((res) => res.arrayBuffer), + Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))), + Effect.as(true), + Effect.catch((err) => Effect.logError("failed to download", { url: url, error: err }).pipe(Effect.as(false))), + ) + }) + + const pull = Effect.fn("Discovery.pull")(function* (url: string) { + const base = url.endsWith("/") ? url : `${url}/` + const index = new URL("index.json", base).href + const host = base.slice(0, -1) + + yield* Effect.logInfo("fetching index", { url: index }) + + const data = yield* HttpClientRequest.get(index).pipe( + HttpClientRequest.acceptJson, + http.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)), + Effect.catch((err) => + Effect.logError("failed to fetch index", { url: index, error: err }).pipe(Effect.as(null)), + ), + ) + + if (!data) return [] + + const missing = data.skills.filter((skill) => !skill.files.includes("SKILL.md")) + yield* Effect.forEach( + missing, + (skill) => Effect.logWarning("skill entry missing SKILL.md", { url: index, skill: skill.name }), + { discard: true }, + ) + const list = data.skills.filter((skill) => skill.files.includes("SKILL.md")) + + const dirs = yield* Effect.forEach( + list, + (skill) => + Effect.gen(function* () { + const root = path.join(cache, skill.name) + const versionFile = path.join(root, ".opencode-version") + const version = skill.version + const current = + version === undefined + ? undefined + : yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined))) + + if (version === undefined || current === version) { + yield* Effect.forEach( + skill.files, + (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), + { concurrency: fileConcurrency, discard: true }, + ) + } else { + const token = crypto.randomUUID() + const staging = `${root}.tmp-${token}` + const backup = `${root}.old-${token}` + yield* Effect.gen(function* () { + const downloaded = yield* Effect.forEach( + skill.files, + (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(staging, file)), + { concurrency: fileConcurrency }, + ) + if (!downloaded.every(Boolean)) return + if (!(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie))) return + yield* fs.writeFileString(path.join(staging, ".opencode-version"), version) + yield* Effect.uninterruptible( + Effect.gen(function* () { + const cached = yield* fs.exists(root).pipe(Effect.orDie) + if (cached) yield* fs.rename(root, backup) + yield* fs.rename(staging, root).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore) + return yield* Effect.fail(error) + }), + ), + ) + if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore) + }), + ) + }).pipe( + Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })), + Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)), + ) + } + return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ? root : null + }), + { concurrency: skillConcurrency }, + ) + + return dirs.filter((dir): dir is string => dir !== null) + }) + + return Service.of({ pull }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, path, httpClient] }) + +export * as Discovery from "./discovery" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a04ec213994a65dd25098b843efca1fbd1c4e0e --- /dev/null +++ b/packages/opencode/src/skill/index.ts @@ -0,0 +1,354 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import path from "path" +import { Effect, Layer, Context, Schema } from "effect" +import { NamedError } from "@opencode-ai/core/util/error" +import type { Agent } from "@/agent/agent" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceState } from "@/effect/instance-state" +import { Global } from "@opencode-ai/core/global" +import { SkillPlugin } from "@opencode-ai/core/plugin/skill" +import { Permission } from "@/permission" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Config } from "@/config/config" +import { FrontmatterError } from "@opencode-ai/core/v1/config/error" +import { ConfigMarkdown } from "@/config/markdown" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Glob } from "@opencode-ai/core/util/glob" +import { Discovery } from "./discovery" +import { isRecord } from "@/util/record" +import { escapeHtml } from "@/util/html" + +const CLAUDE_EXTERNAL_DIR = ".claude" +const AGENTS_EXTERNAL_DIR = ".agents" +const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" +const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" +const SKILL_PATTERN = "**/SKILL.md" + +// Built-in skill that ships with opencode. The model's intuition for what an +// opencode.json should look like is often wrong, and opencode hard-fails on +// invalid config, so users hit cryptic startup errors. Loading this skill +// when the model is asked to touch opencode's own config files gives it the +// actual schemas instead of guesses. +const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" +const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." +const CUSTOMIZE_OPENCODE_SKILL_BODY = SkillPlugin.CustomizeOpencodeContent + +export const Info = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + location: Schema.String, + content: Schema.String, +}) +export type Info = Schema.Schema.Type + +const Issue = Schema.StructWithRest( + Schema.Struct({ + message: Schema.String, + path: Schema.Array(Schema.String), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) + +function isSkillFrontmatter(data: unknown): data is { name: string; description?: string } { + return ( + isRecord(data) && + typeof data.name === "string" && + (data.description === undefined || typeof data.description === "string") + ) +} + +export class InvalidError extends Schema.TaggedErrorClass()("SkillInvalidError", { + path: Schema.String, + message: Schema.optional(Schema.String), + issues: Schema.optional(Schema.Array(Issue)), +}) {} + +export class NameMismatchError extends Schema.TaggedErrorClass()("SkillNameMismatchError", { + path: Schema.String, + expected: Schema.String, + actual: Schema.String, +}) {} + +export class NotFoundError extends Schema.TaggedErrorClass()("Skill.NotFoundError", { + name: Schema.String, + available: Schema.Array(Schema.String), +}) { + override get message() { + return `Skill "${this.name}" not found. Available skills: ${this.available.join(", ") || "none"}` + } +} + +type State = { + skills: Record + dirs: Set +} + +type DiscoveryState = { + matches: string[] + dirs: string[] +} + +type ScanState = { + matches: Set + dirs: Set +} + +export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly require: (name: string) => Effect.Effect + readonly all: () => Effect.Effect + readonly dirs: () => Effect.Effect + readonly available: (agent?: Agent.Info) => Effect.Effect +} + +const add = Effect.fnUntraced(function* (state: State, match: string, events: EventV2Bridge.Service["Service"]) { + const md = yield* Effect.tryPromise({ + try: () => ConfigMarkdown.parse(match), + catch: (err) => err, + }).pipe( + Effect.catch( + Effect.fnUntraced(function* (err) { + const message = FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse skill ${match}` + const { Session } = yield* Effect.promise(() => import("@/session/session")) + yield* events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + yield* Effect.logError("failed to load skill", { skill: match, error: err }) + return undefined + }), + ), + ) + + if (!md) return + + if (!isSkillFrontmatter(md.data)) return + + if (state.skills[md.data.name]) { + yield* Effect.logWarning("duplicate skill name", { + name: md.data.name, + existing: state.skills[md.data.name].location, + duplicate: match, + }) + } + + state.dirs.add(path.dirname(match)) + state.skills[md.data.name] = { + name: md.data.name, + description: md.data.description, + location: match, + content: md.content, + } +}) + +const scan = Effect.fnUntraced(function* ( + state: ScanState, + root: string, + pattern: string, + opts?: { dot?: boolean; scope?: string }, +) { + const matches = yield* Effect.tryPromise({ + try: () => + Glob.scan(pattern, { + cwd: root, + absolute: true, + include: "file", + symlink: true, + dot: opts?.dot, + }), + catch: (error) => error, + }).pipe( + Effect.catch((error) => { + if (!opts?.scope) return Effect.die(error) + return Effect.logError(`failed to scan ${opts.scope} skills`, { dir: root, error: error }).pipe( + Effect.as([] as string[]), + ) + }), + ) + + for (const match of matches) { + state.matches.add(match) + state.dirs.add(path.dirname(match)) + } +}) + +const discoverSkills = Effect.fnUntraced(function* ( + config: Config.Interface, + discovery: Discovery.Interface, + fsys: FSUtil.Interface, + global: Global.Interface, + disableExternalSkills: boolean, + disableClaudeCodeSkills: boolean, + directory: string, + worktree: string, +) { + const state: ScanState = { matches: new Set(), dirs: new Set() } + + const externalDirs: string[] = [] + if (!disableExternalSkills) { + if (!disableClaudeCodeSkills) externalDirs.push(CLAUDE_EXTERNAL_DIR) + externalDirs.push(AGENTS_EXTERNAL_DIR) + + for (const dir of externalDirs) { + const root = path.join(global.home, dir) + if (!(yield* fsys.isDir(root))) continue + yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) + } + + const upDirs = yield* fsys + .up({ targets: externalDirs, start: directory, stop: worktree }) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + + for (const root of upDirs) { + yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) + } + } + + const configDirs = yield* config.directories() + for (const dir of configDirs) { + yield* scan(state, dir, OPENCODE_SKILL_PATTERN) + } + + const cfg = yield* config.get() + for (const item of cfg.skills?.paths ?? []) { + const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item + const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) + if (!(yield* fsys.isDir(dir))) { + yield* Effect.logWarning("skill path not found", { path: dir }) + continue + } + + yield* scan(state, dir, SKILL_PATTERN) + } + + for (const url of cfg.skills?.urls ?? []) { + const pulledDirs = yield* discovery.pull(url) + for (const dir of pulledDirs) { + yield* scan(state, dir, SKILL_PATTERN) + } + } + + return { + matches: Array.from(state.matches), + dirs: Array.from(state.dirs), + } +}) + +const loadSkills = Effect.fnUntraced(function* ( + state: State, + discovered: DiscoveryState, + events: EventV2Bridge.Service["Service"], +) { + yield* Effect.forEach(discovered.matches, (match) => add(state, match, events), { + concurrency: "unbounded", + discard: true, + }) + + yield* Effect.logInfo("init", { count: Object.keys(state.skills).length }) +}) + +export class Service extends Context.Service()("@opencode/Skill") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const discovery = yield* Discovery.Service + const config = yield* Config.Service + const events = yield* EventV2Bridge.Service + const fsys = yield* FSUtil.Service + const global = yield* Global.Service + const flags = yield* RuntimeFlags.Service + const discovered = yield* InstanceState.make( + Effect.fn("Skill.discovery")(function* (ctx) { + return yield* discoverSkills( + config, + discovery, + fsys, + global, + flags.disableExternalSkills, + flags.disableClaudeCodeSkills, + ctx.directory, + ctx.worktree, + ) + }), + ) + const state = yield* InstanceState.make( + Effect.fn("Skill.state")(function* () { + const s: State = { skills: {}, dirs: new Set() } + // Register the built-in skill BEFORE disk discovery so a user-disk + // skill with the same name can override it. + s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { + name: CUSTOMIZE_OPENCODE_SKILL_NAME, + description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + location: "", + content: CUSTOMIZE_OPENCODE_SKILL_BODY, + } + yield* loadSkills(s, yield* InstanceState.get(discovered), events) + return s + }), + ) + + const get = Effect.fn("Skill.get")(function* (name: string) { + const s = yield* InstanceState.get(state) + return s.skills[name] + }) + + const require = Effect.fn("Skill.require")(function* (name: string) { + const s = yield* InstanceState.get(state) + const info = s.skills[name] + if (info) return info + return yield* new NotFoundError({ name, available: Object.keys(s.skills).toSorted() }) + }) + + const all = Effect.fn("Skill.all")(function* () { + const s = yield* InstanceState.get(state) + return Object.values(s.skills) + }) + + const dirs = Effect.fn("Skill.dirs")(function* () { + return (yield* InstanceState.get(discovered)).dirs + }) + + const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { + const s = yield* InstanceState.get(state) + const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name)) + if (!agent) return list + return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny") + }) + + return Service.of({ get, require, all, dirs, available }) + }), +) + +export function fmt(list: Info[], opts: { verbose: boolean }) { + const described = list.filter((skill) => skill.description !== undefined) + if (described.length === 0) return "No skills are currently available." + if (opts.verbose) { + return [ + "", + ...described + .toSorted((a, b) => a.name.localeCompare(b.name)) + .flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ` ${escapeHtml(skill.location)}`, + " ", + ]), + "", + ].join("\n") + } + + return [ + "## Available Skills", + ...described + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((skill) => `- **${skill.name}**: ${skill.description}`), + ].join("\n") +} + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Discovery.node, Config.node, EventV2Bridge.node, FSUtil.node, Global.node, RuntimeFlags.node], +}) + +export * as Skill from "." diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4da9bc3ca86467bddaf19b69e570c9051da0842a --- /dev/null +++ b/packages/opencode/src/snapshot/index.ts @@ -0,0 +1,807 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { formatPatch, structuredPatch } from "diff" +import path from "path" +import { AppProcess } from "@opencode-ai/core/process" +import { InstanceState } from "@/effect/instance-state" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Hash } from "@opencode-ai/core/util/hash" +import { Config } from "@/config/config" +import { Global } from "@opencode-ai/core/global" +import { Info } from "@opencode-ai/schema/file-diff" + +export const Patch = Schema.Struct({ + hash: Schema.String, + files: Schema.mutable(Schema.Array(Schema.String)), +}) +export type Patch = typeof Patch.Type + +export const FileDiff = Info +export type FileDiff = typeof FileDiff.Type + +const prune = "7.days" +const limit = 2 * 1024 * 1024 +const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] +const cfg = ["-c", "core.autocrlf=false", ...core] +const quote = [...cfg, "-c", "core.quotepath=false"] +interface GitResult { + readonly code: ChildProcessSpawner.ExitCode + readonly text: string + readonly stderr: string +} + +type State = Omit + +export interface Interface { + readonly init: () => Effect.Effect + readonly cleanup: () => Effect.Effect + readonly track: () => Effect.Effect + readonly patch: (hash: string) => Effect.Effect + readonly restore: (snapshot: string) => Effect.Effect + readonly revert: (patches: Patch[]) => Effect.Effect + readonly diff: (hash: string) => Effect.Effect + readonly diffFull: (from: string, to: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Snapshot") {} + +const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const appProcess = yield* AppProcess.Service + const config = yield* Config.Service + const locks = new Map() + + const lock = (key: string) => { + const hit = locks.get(key) + if (hit) return hit + + const next = Semaphore.makeUnsafe(1) + locks.set(key, next) + return next + } + + const state = yield* InstanceState.make( + Effect.fn("Snapshot.state")(function* (ctx) { + const state = { + directory: ctx.directory, + worktree: ctx.worktree, + gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)), + vcs: ctx.project.vcs, + } + + const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] + + const encodeNulTerminatedPaths = (files: string[]) => files.join("\0") + "\0" + const encodeTopLevelLiteralPathspecs = (files: string[]) => + encodeNulTerminatedPaths(files.map((file) => `:(top,literal)${file}`)) + + const git = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record; stdin?: string }) { + const result = yield* appProcess.run( + ChildProcess.make("git", cmd, { cwd: opts?.cwd, env: opts?.env, extendEnv: true }), + { stdin: opts?.stdin }, + ) + return { + code: ChildProcessSpawner.ExitCode(result.exitCode), + text: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + } satisfies GitResult + }, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: err instanceof Error ? err.message : String(err), + }), + ), + ) + + const ignore = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return new Set() + // check-ignore treats a leading colon as pathspec magic but accepts and echoes a protective ./ prefix. + const checkIgnorePaths = files.map((item) => (item.startsWith(":") ? `./${item}` : item)) + const check = yield* git( + [ + ...quote, + "--git-dir", + path.join(state.worktree, ".git"), + "--work-tree", + state.worktree, + "check-ignore", + "--no-index", + "--stdin", + "-z", + ], + { + cwd: state.worktree, + stdin: encodeNulTerminatedPaths(checkIgnorePaths), + }, + ) + if (check.code !== 0 && check.code !== 1) return new Set() + return new Set( + check.text + .split("\0") + .filter(Boolean) + .map((item) => (item.startsWith("./:") ? item.slice(2) : item)), + ) + }) + + const drop = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return + yield* git( + [ + ...cfg, + ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]), + ], + { + cwd: state.worktree, + stdin: encodeTopLevelLiteralPathspecs(files), + }, + ) + }) + + const stage = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return + const result = yield* git( + [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])], + { + cwd: state.worktree, + stdin: encodeTopLevelLiteralPathspecs(files), + }, + ) + if (result.code === 0) return + yield* Effect.logWarning("failed to add snapshot files", { + exitCode: result.code, + stderr: result.stderr, + }) + }) + + const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) + const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) + const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) + const locked = (fx: Effect.Effect) => lock(state.gitdir).withPermits(1)(fx) + + const enabled = Effect.fnUntraced(function* () { + if (state.vcs !== "git") return false + return (yield* config.get()).snapshot !== false + }) + + const excludes = Effect.fnUntraced(function* () { + const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { + cwd: state.worktree, + }) + const file = result.text.trim() + if (!file) return + if (!(yield* exists(file))) return + return file + }) + + const sync = Effect.fnUntraced(function* (list: string[] = []) { + const file = yield* excludes() + const target = path.join(state.gitdir, "info", "exclude") + const text = [ + file ? (yield* read(file)).trimEnd() : "", + ...list.map((item) => `/${item.replaceAll("\\", "/")}`), + ] + .filter(Boolean) + .join("\n") + yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie) + yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie) + }) + + // Reuse the hashes for the git storage between the original repo and snapshot + // on huge repos like chromium checkout the git add --all rebuilding the + // hashes can take minutes. By doing this we eliminating this at all + const seed = Effect.fnUntraced(function* () { + if (state.vcs !== "git") return + + const commonDir = yield* git(["rev-parse", "--path-format=absolute", "--git-common-dir"], { + cwd: state.worktree, + }) + + if (commonDir.code !== 0) return + const source = commonDir.text.trim() + if (!source || !(yield* exists(source))) return + + // Share the source object database (and the source's own alternates, + // skipping any that no longer exist) so seeded blobs resolve. + const sourceObjects = path.join(source, "objects") + const chained = (yield* read(path.join(sourceObjects, "info", "alternates"))) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + const alternates: string[] = [] + for (const candidate of [sourceObjects, ...chained]) { + if (yield* exists(candidate)) alternates.push(candidate) + } + if (!alternates.length) return + + yield* fs.ensureDir(path.join(state.gitdir, "objects", "info")).pipe(Effect.orDie) + yield* fs + .writeFileString(path.join(state.gitdir, "objects", "info", "alternates"), alternates.join("\n") + "\n") + .pipe(Effect.orDie) + + // Seed the index from the source repo so already-hashed entries are reused. + // Best-effort: a missing/incompatible index just falls back to a full add. + const sourceIndex = path.join(source, "index") + if (yield* exists(sourceIndex)) { + yield* fs.copyFile(sourceIndex, path.join(state.gitdir, "index")).pipe(Effect.catch(() => Effect.void)) + } + }) + + const add = Effect.fnUntraced(function* () { + yield* sync() + const [diff, other] = yield* Effect.all( + [ + git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], { + cwd: state.directory, + }), + git([...quote, ...args(["ls-files", "--full-name", "--others", "--exclude-standard", "-z", "--", "."])], { + cwd: state.directory, + }), + ], + { concurrency: 2 }, + ) + if (diff.code !== 0 || other.code !== 0) { + yield* Effect.logWarning("failed to list snapshot files", { + diffCode: diff.code, + diffStderr: diff.stderr, + otherCode: other.code, + otherStderr: other.stderr, + }) + return + } + + const tracked = diff.text.split("\0").filter(Boolean) + const untracked = other.text.split("\0").filter(Boolean) + const all = Array.from(new Set([...tracked, ...untracked])) + if (!all.length) return + + // Resolve source-repo ignore rules against the exact candidate set. + // --no-index keeps this pattern-based even when a path is already tracked. + const ignored = yield* ignore(all) + + // Remove newly-ignored files from snapshot index to prevent re-adding + if (ignored.size > 0) { + const ignoredFiles = Array.from(ignored) + yield* Effect.logInfo("removing gitignored files from snapshot", { count: ignoredFiles.length }) + yield* drop(ignoredFiles) + } + + const allow = all.filter((item) => !ignored.has(item)) + if (!allow.length) return + + const large = new Set( + (yield* Effect.all( + allow.map((item) => + fs + .stat(path.join(state.worktree, item)) + .pipe(Effect.catch(() => Effect.void)) + .pipe( + Effect.map((stat) => { + if (!stat || stat.type !== "File") return + const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size + return size > limit ? item : undefined + }), + ), + ), + { concurrency: 8 }, + )).filter((item): item is string => Boolean(item)), + ) + const block = new Set(untracked.filter((item) => large.has(item))) + yield* sync(Array.from(block)) + // Stage only the allowed candidate paths so snapshot updates stay scoped. + yield* stage(allow.filter((item) => !block.has(item))) + }) + + const cleanup = Effect.fnUntraced(function* () { + return yield* locked( + Effect.gen(function* () { + if (!(yield* enabled())) return + if (!(yield* exists(state.gitdir))) return + const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory }) + if (result.code !== 0) { + yield* Effect.logWarning("cleanup failed", { + exitCode: result.code, + stderr: result.stderr, + }) + return + } + yield* Effect.logInfo("cleanup", { prune }) + }), + ) + }) + + const track = Effect.fnUntraced(function* () { + return yield* locked( + Effect.gen(function* () { + if (!(yield* enabled())) return + const existed = yield* exists(state.gitdir) + yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie) + if (!existed) { + yield* git(["init"], { + env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree }, + }) + yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"]) + yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"]) + // Tuning for very large worktrees so the first add stays bounded. + yield* git(["--git-dir", state.gitdir, "config", "feature.manyFiles", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "index.version", "4"]) + yield* git(["--git-dir", state.gitdir, "config", "index.threads", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.untrackedCache", "true"]) + yield* seed() + yield* Effect.logInfo("initialized") + } + yield* add() + const result = yield* git(args(["write-tree"]), { cwd: state.directory }) + const hash = result.text.trim() + yield* Effect.logInfo("tracking", { hash, cwd: state.directory, git: state.gitdir }) + return hash + }), + ) + }) + + const patch = Effect.fnUntraced(function* (hash: string) { + return yield* locked( + Effect.gen(function* () { + yield* add() + const result = yield* git( + [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])], + { + cwd: state.directory, + }, + ) + if (result.code !== 0) { + yield* Effect.logWarning("failed to get diff", { hash, exitCode: result.code }) + return { hash, files: [] } + } + const files = result.text + .trim() + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + + // Hide ignored-file removals from the user-facing patch output. + const ignored = yield* ignore(files) + + return { + hash, + files: files + .filter((item) => !ignored.has(item)) + .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), + } + }), + ) + }) + + const restore = Effect.fnUntraced(function* (snapshot: string) { + return yield* locked( + Effect.gen(function* () { + yield* Effect.logInfo("restore", { commit: snapshot }) + const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree }) + if (result.code === 0) { + const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { + cwd: state.worktree, + }) + if (checkout.code === 0) return + yield* Effect.logError("failed to restore snapshot", { + snapshot, + exitCode: checkout.code, + stderr: checkout.stderr, + }) + return + } + yield* Effect.logError("failed to restore snapshot", { + snapshot, + exitCode: result.code, + stderr: result.stderr, + }) + }), + ) + }) + + const revert = Effect.fnUntraced(function* (patches: Patch[]) { + return yield* locked( + Effect.gen(function* () { + const ops: { hash: string; file: string; rel: string }[] = [] + const seen = new Set() + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue + seen.add(file) + ops.push({ + hash: item.hash, + file, + rel: path.relative(state.worktree, file).replaceAll("\\", "/"), + }) + } + } + + const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) { + yield* Effect.logInfo("reverting", { file: op.file, hash: op.hash }) + const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], { + cwd: state.worktree, + }) + if (result.code === 0) return + const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], { + cwd: state.worktree, + }) + if (tree.code === 0 && tree.text.trim()) { + yield* Effect.logInfo("file existed in snapshot but checkout failed, keeping", { + file: op.file, + hash: op.hash, + }) + return + } + yield* Effect.logInfo("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + yield* remove(op.file) + }) + + const clash = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) + + for (let i = 0; i < ops.length; ) { + const first = ops[i]! + const run = [first] + let j = i + 1 + // Only batch adjacent files when their paths cannot affect each other. + while (j < ops.length && run.length < 100) { + const next = ops[j]! + if (next.hash !== first.hash) break + if (run.some((item) => clash(item.rel, next.rel))) break + run.push(next) + j += 1 + } + + if (run.length === 1) { + yield* single(first) + i = j + continue + } + + const tree = yield* git( + [...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])], + { + cwd: state.worktree, + }, + ) + + if (tree.code !== 0) { + yield* Effect.logInfo("batched ls-tree failed, falling back to single-file revert", { + hash: first.hash, + files: run.length, + }) + for (const op of run) { + yield* single(op) + } + i = j + continue + } + + const have = new Set( + tree.text + .trim() + .split("\n") + .map((item) => item.trim()) + .filter(Boolean), + ) + const list = run.filter((item) => have.has(item.rel)) + if (list.length) { + yield* Effect.logInfo("reverting", { hash: first.hash, files: list.length }) + const result = yield* git( + [...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])], + { + cwd: state.worktree, + }, + ) + if (result.code !== 0) { + yield* Effect.logInfo("batched checkout failed, falling back to single-file revert", { + hash: first.hash, + files: list.length, + }) + for (const op of run) { + yield* single(op) + } + i = j + continue + } + } + + for (const op of run) { + if (have.has(op.rel)) continue + yield* Effect.logInfo("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + yield* remove(op.file) + } + + i = j + } + }), + ) + }) + + const diff = Effect.fnUntraced(function* (hash: string) { + return yield* locked( + Effect.gen(function* () { + yield* add() + const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], { + cwd: state.worktree, + }) + if (result.code !== 0) { + yield* Effect.logWarning("failed to get diff", { + hash, + exitCode: result.code, + stderr: result.stderr, + }) + return "" + } + return result.text.trim() + }), + ) + }) + + const diffFull = Effect.fnUntraced(function* (from: string, to: string) { + return yield* locked( + Effect.gen(function* () { + type Row = { + file: string + status: "added" | "deleted" | "modified" + binary: boolean + additions: number + deletions: number + } + + type Ref = { + file: string + side: "before" | "after" + ref: string + } + + const show = Effect.fnUntraced(function* (row: Row) { + if (row.binary) return ["", ""] + if (row.status === "added") { + return [ + "", + yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), + ] + } + if (row.status === "deleted") { + return [ + yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe( + Effect.map((item) => item.text), + ), + "", + ] + } + return yield* Effect.all( + [ + git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)), + git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), + ], + { concurrency: 2 }, + ) + }) + + const load = Effect.fnUntraced( + function* (rows: Row[]) { + const refs = rows.flatMap((row) => { + if (row.binary) return [] + if (row.status === "added") + return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref] + if (row.status === "deleted") { + return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref] + } + return [ + { file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref, + { file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref, + ] + }) + if (!refs.length) return new Map() + + const batch = yield* appProcess.run( + ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], { + cwd: state.directory, + extendEnv: true, + }), + { stdin: refs.map((item) => item.ref).join("\n") + "\n" }, + ) + if (batch.exitCode !== 0) { + yield* Effect.logInfo( + "git cat-file --batch failed during snapshot diff, falling back to per-file git show", + { + stderr: batch.stderr.toString("utf8"), + refs: refs.length, + }, + ) + return + } + const out = batch.stdout + + const fail = (msg: string, extra?: Record) => { + return undefined + } + + const map = new Map() + const dec = new TextDecoder() + let i = 0 + for (const ref of refs) { + let end = i + while (end < out.length && out[end] !== 10) end += 1 + if (end >= out.length) { + return fail( + "git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show", + ) + } + + const head = dec.decode(out.slice(i, end)) + i = end + 1 + const hit = map.get(ref.file) ?? { before: "", after: "" } + if (head.endsWith(" missing")) { + map.set(ref.file, hit) + continue + } + + const match = head.match(/^[0-9a-f]+ blob (\d+)$/) + if (!match) { + return fail( + "git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show", + { head }, + ) + } + + const size = Number(match[1]) + if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) { + return fail( + "git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show", + { head }, + ) + } + + const text = dec.decode(out.slice(i, i + size)) + if (ref.side === "before") hit.before = text + if (ref.side === "after") hit.after = text + map.set(ref.file, hit) + i += size + 1 + } + + if (i !== out.length) { + return fail( + "git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show", + ) + } + + return map + }, + Effect.scoped, + Effect.catch(() => + Effect.succeed | undefined>(undefined), + ), + ) + + const result: FileDiff[] = [] + const status = new Map() + + const statuses = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], + { cwd: state.directory }, + ) + + for (const line of statuses.text.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") + } + + const numstat = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], + { + cwd: state.directory, + }, + ) + + const rows = numstat.text + .trim() + .split("\n") + .filter(Boolean) + .flatMap((line) => { + const [adds, dels, file] = line.split("\t") + if (!file) return [] + const binary = adds === "-" && dels === "-" + const additions = binary ? 0 : parseInt(adds) + const deletions = binary ? 0 : parseInt(dels) + return [ + { + file, + status: status.get(file) ?? "modified", + binary, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Row, + ] + }) + + // Hide ignored-file removals from the user-facing diff output. + const ignored = yield* ignore(rows.map((r) => r.file)) + if (ignored.size > 0) { + const filtered = rows.filter((r) => !ignored.has(r.file)) + rows.length = 0 + rows.push(...filtered) + } + + const step = 100 + const patch = (file: string, before: string, after: string) => + formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) + + for (let i = 0; i < rows.length; i += step) { + const run = rows.slice(i, i + step) + const text = yield* load(run) + + for (const row of run) { + const hit = text?.get(row.file) ?? { before: "", after: "" } + const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row) + result.push({ + file: row.file, + patch: row.binary ? "" : patch(row.file, before, after), + additions: row.additions, + deletions: row.deletions, + status: row.status, + }) + } + } + + return result + }), + ) + }) + + yield* cleanup().pipe( + Effect.catchCause((cause) => Effect.logError("cleanup loop failed", { cause: Cause.pretty(cause) })), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, + ) + + return { cleanup, track, patch, restore, revert, diff, diffFull } + }), + ) + + return Service.of({ + init: Effect.fn("Snapshot.init")(function* () { + yield* InstanceState.get(state) + }), + cleanup: Effect.fn("Snapshot.cleanup")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.cleanup()) + }), + track: Effect.fn("Snapshot.track")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.track()) + }), + patch: Effect.fn("Snapshot.patch")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.patch(hash)) + }), + restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) { + return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot)) + }), + revert: Effect.fn("Snapshot.revert")(function* (patches: Patch[]) { + return yield* InstanceState.useEffect(state, (s) => s.revert(patches)) + }), + diff: Effect.fn("Snapshot.diff")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.diff(hash)) + }), + diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { + return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to)) + }), + }) + }), +) + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, AppProcess.node, Config.node], +}) + +export * as Snapshot from "." diff --git a/packages/opencode/src/sql.d.ts b/packages/opencode/src/sql.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..28cb1e26492dd8f0dd05d1052b6ced4bb996d6e8 --- /dev/null +++ b/packages/opencode/src/sql.d.ts @@ -0,0 +1,4 @@ +declare module "*.sql" { + const content: string + export default content +} diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..06d095f06d4ccf127ae65fa339295e6a793998f2 --- /dev/null +++ b/packages/opencode/src/storage/schema.ts @@ -0,0 +1,5 @@ +export { AccountTable, AccountStateTable, ControlAccountTable } from "@opencode-ai/core/account/sql" +export { ProjectTable } from "@opencode-ai/core/project/sql" +export { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql" +export { SessionShareTable } from "@opencode-ai/core/share/sql" +export { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts new file mode 100644 index 0000000000000000000000000000000000000000..ddbd83d7931ca7e45d22df000ccdf79075ec8ca9 --- /dev/null +++ b/packages/opencode/src/storage/storage.ts @@ -0,0 +1,327 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import path from "path" +import { Global } from "@opencode-ai/core/global" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" +import { NonNegativeInt } from "@opencode-ai/core/schema" +import { Git } from "@/git" + +type Migration = (dir: string, fs: FSUtil.Interface, git: Git.Interface) => Effect.Effect + +export class NotFoundError extends Schema.TaggedErrorClass()("NotFoundError", { + message: Schema.String, +}) { + static isInstance(input: unknown): input is NotFoundError { + return input instanceof NotFoundError + } +} + +export type Error = FSUtil.Error | NotFoundError + +const RootFile = Schema.Struct({ + path: Schema.optional( + Schema.Struct({ + root: Schema.optional(Schema.String), + }), + ), +}) + +const SessionFile = Schema.Struct({ + id: Schema.String, +}) + +const MessageFile = Schema.Struct({ + id: Schema.String, +}) + +const DiffFile = Schema.Struct({ + additions: NonNegativeInt, + deletions: NonNegativeInt, +}) + +const SummaryFile = Schema.Struct({ + id: Schema.String, + projectID: Schema.String, + summary: Schema.Struct({ diffs: Schema.Array(DiffFile) }), +}) + +const decodeRoot = Schema.decodeUnknownOption(RootFile) +const decodeSession = Schema.decodeUnknownOption(SessionFile) +const decodeMessage = Schema.decodeUnknownOption(MessageFile) +const decodeSummary = Schema.decodeUnknownOption(SummaryFile) + +export interface Interface { + readonly remove: (key: string[]) => Effect.Effect + readonly read: (key: string[]) => Effect.Effect + readonly update: (key: string[], fn: (draft: T) => void) => Effect.Effect + readonly write: (key: string[], content: T) => Effect.Effect + readonly list: (prefix: string[]) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Storage") {} + +function file(dir: string, key: string[]) { + return path.join(dir, ...key) + ".json" +} + +function missing(err: unknown) { + if (!err || typeof err !== "object") return false + if ("code" in err && err.code === "ENOENT") return true + if ("reason" in err && err.reason && typeof err.reason === "object" && "_tag" in err.reason) { + return err.reason._tag === "NotFound" + } + return false +} + +function parseMigration(text: string) { + const value = Number.parseInt(text, 10) + return Number.isNaN(value) ? 0 : value +} + +const MIGRATIONS: Migration[] = [ + Effect.fn("Storage.migration.1")(function* (dir: string, fs: FSUtil.Interface, git: Git.Interface) { + const project = path.resolve(dir, "../project") + if (!(yield* fs.isDir(project))) return + const projectDirs = yield* fs.glob("*", { + cwd: project, + include: "all", + }) + for (const projectDir of projectDirs) { + const full = path.join(project, projectDir) + if (!(yield* fs.isDir(full))) continue + yield* Effect.logInfo(`migrating project ${projectDir}`) + let projectID = projectDir + let worktree = "/" + + if (projectID !== "global") { + for (const msgFile of yield* fs.glob("storage/session/message/*/*.json", { + cwd: full, + absolute: true, + })) { + const json = decodeRoot(yield* fs.readJson(msgFile), { onExcessProperty: "preserve" }) + const root = Option.isSome(json) ? json.value.path?.root : undefined + if (!root) continue + worktree = root + break + } + if (!worktree) continue + if (!(yield* fs.isDir(worktree))) continue + const result = yield* git.run(["rev-list", "--max-parents=0", "--all"], { + cwd: worktree, + }) + const [id] = result + .text() + .split("\n") + .filter(Boolean) + .map((x) => x.trim()) + .toSorted() + if (!id) continue + projectID = id + + yield* fs.writeWithDirs( + path.join(dir, "project", projectID + ".json"), + JSON.stringify( + { + id, + vcs: "git", + worktree, + time: { + created: Date.now(), + initialized: Date.now(), + }, + }, + null, + 2, + ), + ) + + yield* Effect.logInfo(`migrating sessions for project ${projectID}`) + for (const sessionFile of yield* fs.glob("storage/session/info/*.json", { + cwd: full, + absolute: true, + })) { + const dest = path.join(dir, "session", projectID, path.basename(sessionFile)) + yield* Effect.logInfo("copying", { sessionFile, dest }) + const session = yield* fs.readJson(sessionFile) + const info = decodeSession(session, { onExcessProperty: "preserve" }) + yield* fs.writeWithDirs(dest, JSON.stringify(session, null, 2)) + if (Option.isNone(info)) continue + yield* Effect.logInfo(`migrating messages for session ${info.value.id}`) + for (const msgFile of yield* fs.glob(`storage/session/message/${info.value.id}/*.json`, { + cwd: full, + absolute: true, + })) { + const next = path.join(dir, "message", info.value.id, path.basename(msgFile)) + yield* Effect.logInfo("copying", { + msgFile, + dest: next, + }) + const message = yield* fs.readJson(msgFile) + const item = decodeMessage(message, { onExcessProperty: "preserve" }) + yield* fs.writeWithDirs(next, JSON.stringify(message, null, 2)) + if (Option.isNone(item)) continue + + yield* Effect.logInfo(`migrating parts for message ${item.value.id}`) + for (const partFile of yield* fs.glob(`storage/session/part/${info.value.id}/${item.value.id}/*.json`, { + cwd: full, + absolute: true, + })) { + const out = path.join(dir, "part", item.value.id, path.basename(partFile)) + const part = yield* fs.readJson(partFile) + yield* Effect.logInfo("copying", { + partFile, + dest: out, + }) + yield* fs.writeWithDirs(out, JSON.stringify(part, null, 2)) + } + } + } + } + } + }), + Effect.fn("Storage.migration.2")(function* (dir: string, fs: FSUtil.Interface) { + for (const item of yield* fs.glob("session/*/*.json", { + cwd: dir, + absolute: true, + })) { + const raw = yield* fs.readJson(item) + const session = decodeSummary(raw, { onExcessProperty: "preserve" }) + if (Option.isNone(session)) continue + const diffs = session.value.summary.diffs + yield* fs.writeWithDirs( + path.join(dir, "session_diff", session.value.id + ".json"), + JSON.stringify(diffs, null, 2), + ) + yield* fs.writeWithDirs( + path.join(dir, "session", session.value.projectID, session.value.id + ".json"), + JSON.stringify( + { + ...(raw as Record), + summary: { + additions: diffs.reduce((sum, x) => sum + x.additions, 0), + deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), + }, + }, + null, + 2, + ), + ) + } + }), +] + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const git = yield* Git.Service + const locks = yield* RcMap.make({ + lookup: () => TxReentrantLock.make(), + idleTimeToLive: 0, + }) + const state = yield* Effect.cached( + Effect.gen(function* () { + const dir = path.join(Global.Path.data, "storage") + const marker = path.join(dir, "migration") + const migration = yield* fs.readFileString(marker).pipe( + Effect.map(parseMigration), + Effect.catchIf(missing, () => Effect.succeed(0)), + Effect.orElseSucceed(() => 0), + ) + for (let i = migration; i < MIGRATIONS.length; i++) { + yield* Effect.logInfo("running migration", { index: i }) + const step = MIGRATIONS[i]! + const exit = yield* Effect.exit(step(dir, fs, git)) + if (Exit.isFailure(exit)) { + yield* Effect.logError("failed to run migration", { index: i, cause: exit.cause }) + break + } + yield* fs.writeWithDirs(marker, String(i + 1)) + } + return { dir } + }), + ) + + const fail = (target: string): Effect.Effect => + Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` })) + + const wrap = (target: string, body: Effect.Effect) => + body.pipe(Effect.catchIf(missing, () => fail(target))) + + const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) { + yield* fs.writeWithDirs(target, JSON.stringify(content, null, 2)) + }) + + const withResolved = ( + key: string[], + fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect, + ): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const target = file((yield* state).dir, key) + return yield* fn(target, yield* RcMap.get(locks, target)) + }), + ) + + const remove: Interface["remove"] = Effect.fn("Storage.remove")(function* (key: string[]) { + yield* withResolved(key, (target, rw) => + TxReentrantLock.withWriteLock(rw, fs.remove(target).pipe(Effect.catchIf(missing, () => Effect.void))), + ) + }) + + const read: Interface["read"] = (key: string[]) => + Effect.gen(function* () { + const value = yield* withResolved(key, (target, rw) => + TxReentrantLock.withReadLock(rw, wrap(target, fs.readJson(target))), + ) + return value as T + }) + + const update: Interface["update"] = (key: string[], fn: (draft: T) => void) => + Effect.gen(function* () { + const value = yield* withResolved(key, (target, rw) => + TxReentrantLock.withWriteLock( + rw, + Effect.gen(function* () { + const content = yield* wrap(target, fs.readJson(target)) + fn(content as T) + yield* writeJson(target, content) + return content + }), + ), + ) + return value as T + }) + + const write: Interface["write"] = (key: string[], content: unknown) => + Effect.gen(function* () { + yield* withResolved(key, (target, rw) => TxReentrantLock.withWriteLock(rw, writeJson(target, content))) + }) + + const list: Interface["list"] = Effect.fn("Storage.list")(function* (prefix: string[]) { + const dir = (yield* state).dir + const cwd = path.join(dir, ...prefix) + const result = yield* fs + .glob("**/*", { + cwd, + include: "file", + }) + .pipe(Effect.catch(() => Effect.succeed([]))) + return result + .map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]) + .toSorted((a, b) => a.join("/").localeCompare(b.join("/"))) + }) + + return Service.of({ + remove, + read, + update, + write, + list, + }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, Git.node] }) + +export * as Storage from "./storage" diff --git a/packages/opencode/src/sync/README.md b/packages/opencode/src/sync/README.md new file mode 100644 index 0000000000000000000000000000000000000000..eec71d3001136d48a5a2f5d4f1cda67d2b54c741 --- /dev/null +++ b/packages/opencode/src/sync/README.md @@ -0,0 +1,179 @@ +tl;dr All of these APIs work, are properly type-checked, and are sync events are backwards compatible with `Bus`: + +```ts +// The schema from `Updated` typechecks the object correctly +SyncEvent.run(Updated, { sessionID: id, info: { title: "foo" } }) + +// `subscribeAll` passes a generic sync event +SyncEvent.subscribeAll((event) => { + // These will be type-checked correctly + event.id + event.seq + // This will be unknown because we are listening for all events, + // and this API is only used to record them + event.data +}) + +// This works, but you shouldn't publish sync event like this (should fail in the future) +Bus.publish(Updated, { sessionID: id, info: { title: "foo" } }) + +// Update event is fully type-checked +Bus.subscribe(Updated, (event) => event.properties.info.title) + +// Update event is fully type-checked +client.subscribe("session.updated", (evt) => evt.properties.info.title) +``` + +# Goal + +## Syncing with only one writer + +This system defines a basic event sourcing system for session replayability. The goal is to allow for one device to control and modify the session, and allow multiple other devices to "sync" session data. The sync works by getting a log of events to replay and replaying them locally. + +Because only one device is allowed to write, we don't need any kind of sophisticated distributed system clocks or causal ordering. We implement total ordering with a simple sequence id (a number) and increment it by one every time we generate an event. + +## Bus event integration and backwards compatibility + +This initial implementation aims to be fully backwards compatible. We should be able to land this without any visible changes to the user. + +An existing `Bus` abstraction to send events already exists. We already send events like `session.created` through the system. We should not duplicate this. + +The difference in event sourcing is events are sent _before_ the mutation happens, and "projectors" handle the effects and perform the mutations. This difference is subtle, and a necessary change for syncing to work. + +So the goal is: + +- Introduce a new syncing abstraction to handle event sourcing and projectors +- Seamlessly integrate these new events into the same existing `Bus` abstraction +- Maintain full backwards compatibility to reduce risk + +## My approach + +This directory introduces a new abstraction: `SyncEvent`. This handles all of the event sourcing. + +There are now "sync events" which are different than "bus events". Bus events are defined like this: + +```ts +const Diff = BusEvent.define( + "session.diff", + Schema.Struct({ + sessionID: SessionID, + diff: Schema.Array(Snapshot.FileDiff), + }), +) +``` + +You can do `Bus.publish(Diff, { ... })` to push these events, and `Bus.subscribe(Diff, handler)` to listen to them. + +Sync events are a lower-level abstraction which are similar, but also handle the requirements for recording and replaying. Defining them looks like this: + +```ts +const Created = SyncEvent.define({ + type: "session.created", + version: 1, + aggregate: "sessionID", + schema: Schema.Struct({ + sessionID: SessionID, + info: Info, + }), +}) +``` + +Not too different, except they track a version and an "aggregate" field (will explain that later). + +You do this to run an event, which is kind of like `Bus.publish` except that it runs through the event sourcing system: + +``` +SyncEvent.run(Created, { ... }) +``` + +The data passed as the second argument is properly type-checked based on the schema defined in `Created`. + +Importantly, **sync events automatically re-publish as bus events**. This makes them backwards compatible, and allows the `Bus` to still be the single abstraction that the system uses to listen for individual events. + +**We have upgraded many of the session events to be sync events** (all of the ones that mutate the db). Sync and bus events are largely compatible. Here are the differences: + +### Event shape + +- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event through the bus. + +The reason for this is because sync events need to track more information. I chose not to copy the `properties` naming to more clearly disambiguate the event types. + +### Event flow + +There is no way to subscribe to individual sync events in `SyncEvent`. You can use `subscribeAll` to receive _all_ of the events, which is needed for clients that want to record them. + +To listen for individual events, use `Bus.subscribe`. You can pass in a sync event definition to it: `Bus.subscribe(Created, handler)`. This is fully supported. + +You should never "publish" a sync event however: `Bus.publish(Created, ...)`. I would like to force this to be a type error in the future. You should never be touching the db directly, and should not be manually handling these events. + +### Backwards compatibility + +The system install projectors in `server/projectors.js`. It calls `SyncEvent.init` to do this. It also installs a hook for dynamically converting an event at runtime (`convertEvent`). + +This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat. + +The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync event only contains the fields updated. We convert the event to contain the full object for backwards compatibility (but ideally we'd remove this). + +It's very important that types are correct when working with events. Event definitions have a `schema` which carries the definition of the event shape. Examples: + +```ts +// The schema from `Updated` typechecks the object correctly +SyncEvent.run(Updated, { sessionID: id, info: { title: "foo" } }) + +// `subscribeAll` passes a generic sync event +SyncEvent.subscribeAll((event) => { + // These will be type-checked correctly + event.id + event.seq + // This will be unknown because we are listening for all events, + // and this API is only used to record them + event.data +}) + +// This works, but you shouldn't publish sync event like this (should fail in the future) +Bus.publish(Updated, { sessionID: id, info: { title: "foo" } }) + +// Update event is fully type-checked +Bus.subscribe(Updated, (event) => event.properties.info.title) + +// Update event is fully type-checked +client.subscribe("session.updated", (evt) => evt.properties.info.title) +``` + +The last two examples look similar to `SyncEvent.run`, but they were the cause of a lot of grief. Those are existing APIs that we can't break, but we are passing in the new sync event definitions to these APIs, which sometimes have a different event shape. + +I previously mentioned the runtime conversion of events, but we still need to the types to work! To do that, the `define` API supports an optional `busSchema` prop to give it the schema for backwards compatibility. For example this is the full definition of `Session.Update`: + +```ts +const Update = SyncEvent.define({ + type: "session.updated", + version: 1, + aggregate: "sessionID", + schema: Schema.Struct({ + sessionID: SessionID, + info: partialSchema(Info), + }), + busSchema: Schema.Struct({ + sessionID: SessionID, + info: Info, + }), +}) +``` + +_Important_: the conversion done in `convertEvent` is not automatically type-checked with `busSchema`. It's very important they match, but because we need this at type-checking time this needs to live here. + +Internally, the way this works is `busSchema` is stored on a `properties` field which is what the bus system expects. Doing this made everything with `Bus` "just work". This is why you can pass a sync event to the bus APIs. + +_Alternatives_ + +These are some other paths I explored: + +- Providing a way to subscribe to individual sync events, and change all the instances of `Bus.subscribe` in our code to it. Then you are directly only working with sync events always. + - Two big problems. First, `Bus` is instance-scoped, and we'd need to make the sync event system instance-scoped too for backwards compat. If we didn't, those listeners would get calls for events they weren't expecting. + - Second, we can't change consumers of our SDK. So they still have to use the old events, and we might as well stick with them for consistency +- Directly add sync event support to bus system + - I explored adding sync events to the bus, but due to backwards compat, it only made it more complicated (still need to support both shapes) +- I explored a `convertSchema` function to convert the event schema at runtime so we didn't need `busSchema` + - Fatal flaw: we need type-checking done earlier. We can't do this at run-time. This worked for consumers of our SDK (because it gets generated TS types from the converted schema) but breaks for our internal usage of `Bus.subscribe` calls + +I explored many other permutations of the above solutions. What we have today I think is the best balance of backwards compatibility while opening a path forward for the new events. diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..39b9b92b4562851a85bc19c175c6b45ac3b48916 --- /dev/null +++ b/packages/opencode/src/sync/schema.ts @@ -0,0 +1,11 @@ +import { Schema } from "effect" + +import { Identifier } from "@/id/id" +import { statics } from "@opencode-ai/core/schema" + +export const EventID = Schema.String.check(Schema.isStartsWith("evt")).pipe( + Schema.brand("EventID"), + statics((s) => ({ + ascending: (id?: string) => s.make(Identifier.ascending("event", id)), + })), +) diff --git a/packages/opencode/src/temporary.ts b/packages/opencode/src/temporary.ts new file mode 100644 index 0000000000000000000000000000000000000000..95461f301b5cf4929458c72b4fb0458942ac4b17 --- /dev/null +++ b/packages/opencode/src/temporary.ts @@ -0,0 +1,31 @@ +import yargs from "yargs" +import { TuiThreadCommand } from "./cli/cmd/tui" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { hideBin } from "yargs/helpers" +const cli = yargs(hideBin(process.argv)) + .parserConfiguration({ "populate--": true }) + .scriptName("opencode") + .wrap(100) + .help("help", "show help") + .alias("help", "h") + .version("version", "show version number", InstallationVersion) + .alias("version", "v") + .option("print-logs", { + describe: "print logs to stderr", + type: "boolean", + }) + .option("log-level", { + describe: "log level", + type: "string", + choices: ["DEBUG", "INFO", "WARN", "ERROR"], + }) + .option("pure", { + describe: "run without external plugins", + type: "boolean", + }) + .middleware((opts) => { + if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1" + if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel + }) + .command(TuiThreadCommand) + .parse() diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f89a63974b0d1927ac698d5001f7fd04fc79392 --- /dev/null +++ b/packages/opencode/src/tool/apply_patch.ts @@ -0,0 +1,313 @@ +import * as path from "path" +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { InstanceState } from "@/effect/instance-state" +import { Patch } from "../patch" +import { createTwoFilesPatch, diffLines } from "diff" +import { assertExternalDirectoryEffect } from "./external-directory" +import { trimDiff } from "./edit" +import { LSP } from "@/lsp/lsp" +import { FSUtil } from "@opencode-ai/core/fs-util" +import DESCRIPTION from "./apply_patch.txt" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { Format } from "../format" +import * as Bom from "@/util/bom" + +export const Parameters = Schema.Struct({ + patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }), +}) + +export const ApplyPatchTool = Tool.define( + "apply_patch", + Effect.gen(function* () { + const lsp = yield* LSP.Service + const afs = yield* FSUtil.Service + const format = yield* Format.Service + const events = yield* EventV2Bridge.Service + + const run = Effect.fn("ApplyPatchTool.execute")(function* ( + params: Schema.Schema.Type, + ctx: Tool.Context, + ) { + if (!params.patchText) { + return yield* Effect.fail(new Error("patchText is required")) + } + + // Parse the patch to get hunks + let hunks: Patch.Hunk[] + try { + const parseResult = Patch.parsePatch(params.patchText) + hunks = parseResult.hunks + } catch (error) { + return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`)) + } + + if (hunks.length === 0) { + const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim() + if (normalized === "*** Begin Patch\n*** End Patch") { + return yield* Effect.fail(new Error("patch rejected: empty patch")) + } + return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found")) + } + + const instance = yield* InstanceState.context + + // Validate file paths and check permissions + const fileChanges: Array<{ + filePath: string + oldContent: string + newContent: string + type: "add" | "update" | "delete" | "move" + movePath?: string + diff: string + additions: number + deletions: number + bom: boolean + }> = [] + + let totalDiff = "" + + for (const hunk of hunks) { + const filePath = path.resolve(instance.directory, hunk.path) + yield* assertExternalDirectoryEffect(ctx, filePath) + + switch (hunk.type) { + case "add": { + const oldContent = "" + const newContent = + hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` + const next = Bom.split(newContent) + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, next.text)) + + let additions = 0 + let deletions = 0 + for (const change of diffLines(oldContent, next.text)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + + fileChanges.push({ + filePath, + oldContent, + newContent: next.text, + type: "add", + diff, + additions, + deletions, + bom: next.bom, + }) + + totalDiff += diff + "\n" + break + } + + case "update": { + // Check if file exists for update + const stats = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!stats || stats.type === "Directory") { + return yield* Effect.fail( + new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`), + ) + } + + const source = yield* Bom.readFile(afs, filePath) + const oldContent = source.text + let newContent = oldContent + let bom = source.bom + + // Apply the update chunks to get new content + try { + const fileUpdate = Patch.deriveNewContentsFromChunks( + filePath, + hunk.chunks, + Bom.join(source.text, source.bom), + ) + newContent = fileUpdate.content + bom = fileUpdate.bom + } catch (error) { + return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`)) + } + + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + + let additions = 0 + let deletions = 0 + for (const change of diffLines(oldContent, newContent)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + + const movePath = hunk.move_path ? path.resolve(instance.directory, hunk.move_path) : undefined + yield* assertExternalDirectoryEffect(ctx, movePath) + + fileChanges.push({ + filePath, + oldContent, + newContent, + type: hunk.move_path ? "move" : "update", + movePath, + diff, + additions, + deletions, + bom, + }) + + totalDiff += diff + "\n" + break + } + + case "delete": { + const source = yield* Bom.readFile(afs, filePath).pipe( + Effect.catch((error) => + Effect.fail( + new Error( + `apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`, + ), + ), + ), + ) + const contentToDelete = source.text + const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) + + const deletions = contentToDelete.split("\n").length + + fileChanges.push({ + filePath, + oldContent: contentToDelete, + newContent: "", + type: "delete", + diff: deleteDiff, + additions: 0, + deletions, + bom: source.bom, + }) + + totalDiff += deleteDiff + "\n" + break + } + } + } + + // Build per-file metadata for UI rendering (used for both permission and result) + const files = fileChanges.map((change) => ({ + filePath: change.filePath, + relativePath: path.relative(instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), + type: change.type, + patch: change.diff, + additions: change.additions, + deletions: change.deletions, + ...(change.movePath ? { movePath: change.movePath } : {}), + })) + + // Check permissions if needed + const relativePaths = fileChanges.map((c) => path.relative(instance.worktree, c.filePath).replaceAll("\\", "/")) + yield* ctx.ask({ + permission: "edit", + patterns: relativePaths, + always: ["*"], + metadata: { + filepath: relativePaths.join(", "), + diff: totalDiff, + files, + }, + }) + + // Apply the changes + const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = [] + + for (const change of fileChanges) { + const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath) + switch (change.type) { + case "add": + // Create parent directories (recursive: true is safe on existing/root dirs) + + yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom)) + updates.push({ file: change.filePath, event: "add" }) + break + + case "update": + yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom)) + updates.push({ file: change.filePath, event: "change" }) + break + + case "move": + if (change.movePath) { + // Create parent directories (recursive: true is safe on existing/root dirs) + + yield* afs.writeWithDirs(change.movePath!, Bom.join(change.newContent, change.bom)) + yield* afs.remove(change.filePath) + updates.push({ file: change.filePath, event: "unlink" }) + updates.push({ file: change.movePath, event: "add" }) + } + break + + case "delete": + yield* afs.remove(change.filePath) + updates.push({ file: change.filePath, event: "unlink" }) + break + } + + if (edited) { + if (yield* format.file(edited)) { + yield* Bom.syncFile(afs, edited, change.bom) + } + yield* events.publish(FileSystem.Event.Edited, { file: edited }) + } + } + + // Publish file change events + for (const update of updates) { + yield* events.publish(Watcher.Event.Updated, update) + } + + // Notify LSP of file changes and collect diagnostics + for (const change of fileChanges) { + if (change.type === "delete") continue + const target = change.movePath ?? change.filePath + yield* lsp.touchFile(target, "document") + } + const diagnostics = yield* lsp.diagnostics() + + // Generate output summary + const summaryLines = fileChanges.map((change) => { + if (change.type === "add") { + return `A ${path.relative(instance.worktree, change.filePath).replaceAll("\\", "/")}` + } + if (change.type === "delete") { + return `D ${path.relative(instance.worktree, change.filePath).replaceAll("\\", "/")}` + } + const target = change.movePath ?? change.filePath + return `M ${path.relative(instance.worktree, target).replaceAll("\\", "/")}` + }) + let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` + + for (const change of fileChanges) { + if (change.type === "delete") continue + const target = change.movePath ?? change.filePath + const block = LSP.Diagnostic.report(target, diagnostics[FSUtil.normalizePath(target)] ?? []) + if (!block) continue + const rel = path.relative(instance.worktree, target).replaceAll("\\", "/") + output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}` + } + + return { + title: output, + metadata: { + diff: totalDiff, + files, + diagnostics, + }, + output, + } + }) + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + run(params, ctx).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/apply_patch.txt b/packages/opencode/src/tool/apply_patch.txt new file mode 100644 index 0000000000000000000000000000000000000000..5b2d95608c532194a386a79a05e1aa38e2f97f97 --- /dev/null +++ b/packages/opencode/src/tool/apply_patch.txt @@ -0,0 +1,33 @@ +Use the `apply_patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope: + +*** Begin Patch +[ one or more file sections ] +*** End Patch + +Within that envelope, you get a sequence of file operations. +You MUST include a header to specify the action you are taking. +Each operation starts with one of three headers: + +*** Add File: - create a new file. Every following line is a + line (the initial contents). +*** Delete File: - remove an existing file. Nothing follows. +*** Update File: - patch an existing file in place (optionally with a rename). + +Example patch: + +``` +*** Begin Patch +*** Add File: hello.txt ++Hello world +*** Update File: src/app.py +*** Move to: src/main.py +@@ def greet(): +-print("Hi") ++print("Hello, world!") +*** Delete File: obsolete.txt +*** End Patch +``` + +It is important to remember: + +- You must include a header with your intended action (Add/Delete/Update) +- You must prefix new lines with `+` even when creating a new file diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts new file mode 100644 index 0000000000000000000000000000000000000000..332d4b43f150dcf9047ac70bb5313ecd4e187121 --- /dev/null +++ b/packages/opencode/src/tool/code-mode.ts @@ -0,0 +1,310 @@ +import * as Tool from "./tool" +import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js" +import { Cause, Effect, Schema } from "effect" +import { CodeMode, Tool as SandboxTool, toolError } from "@opencode-ai/codemode" +import { MCP } from "@/mcp" +import { McpCatalog } from "@/mcp/catalog" +import { Agent } from "@/agent/agent" +import { Session } from "@/session/session" +import { Permission } from "@/permission" +import { Plugin } from "@/plugin" + +export const CODE_MODE_TOOL = "execute" + +const DESCRIPTION = "Run a confined orchestration script with access to connected MCP tools." + +export const Parameters = Schema.Struct({ + code: Schema.String.annotate({ + description: "Script body executed by the confined interpreter.", + }), +}) + +type CallEntry = { tool: string; status: "running" | "completed" | "error"; input?: Record } + +type Metadata = { + toolCalls: CallEntry[] + error?: boolean +} + +type Attachment = NonNullable[number] + +type CatalogEntry = { + path: string + key: string + server: string + local: string + tool: MCP.McpTool +} + +function groupByServer(mcpTools: Record, servers: readonly string[]): Map { + const byLongest = [...servers].sort((a, b) => b.length - a.length) + const groups = new Map() + for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) { + const server = + byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key) + const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key + const entry: CatalogEntry = { + path: `${server}.${local}`, + key, + server, + local, + tool: mcpTools[key]!, + } + groups.set(server, [...(groups.get(server) ?? []), entry]) + } + return groups +} + +export function describeCatalog(mcpTools: Record, servers: readonly string[]): string { + return CodeMode.make({ + tools: toolTree( + [...groupByServer(mcpTools, servers).values()].flat(), + () => () => Effect.fail(toolError("Tool preview is not executable.")), + ), + }).instructions() +} + +const lastSegment = (uri: string) => { + const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "") + const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1) + return segment.length > 0 ? segment : undefined +} + +const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}` + +function projectMcpResult(result: CallToolResult, collect: (attachment: Attachment) => void): unknown { + const text: string[] = [] + let files = 0 + let images = 0 + const push = (attachment: Attachment) => { + files += 1 + if (attachment.mime.startsWith("image/")) images += 1 + collect(attachment) + } + for (const block of result.content) { + switch (block.type) { + case "text": + text.push(block.text) + break + case "image": + case "audio": + push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) }) + break + case "resource": { + if ("text" in block.resource) { + text.push(block.resource.text) + break + } + const mime = block.resource.mimeType ?? "application/octet-stream" + push({ type: "file", mime, url: dataUrl(mime, block.resource.blob), filename: lastSegment(block.resource.uri) }) + break + } + case "resource_link": + // A link is a reference, not fetchable media; hand it to the program instead of the attachment channel. + text.push(`${block.name}: ${block.uri}`) + break + } + } + + if (result.structuredContent !== undefined && result.structuredContent !== null) return result.structuredContent + if (text.length > 0) return text.join("\n") + if (files > 0) { + const noun = files === images ? "image" : "file" + return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]` + } + return null +} + +type Run = (input: unknown) => Effect.Effect + +function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) => Run) { + const tree: Record> = {} + for (const entry of catalog) { + const namespace = (tree[entry.server] ??= {}) + namespace[entry.local] = SandboxTool.make({ + description: entry.tool.def.description ?? "", + input: entry.tool.def.inputSchema as SandboxTool.JsonSchema, + output: entry.tool.def.outputSchema as SandboxTool.JsonSchema | undefined, + run: run(entry), + }) + } + return tree +} + +const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: { + plugin: Plugin.Interface + entry: CatalogEntry + args: Record + callID: string + ctx: Tool.Context +}) { + yield* input.plugin.trigger( + "tool.execute.before", + { tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID }, + { args: input.args }, + ) + const result: CallToolResult = yield* Effect.gen(function* () { + yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] }) + // Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns. + return yield* Effect.promise(async () => { + const raw = await input.entry.tool.client.callTool( + { name: input.entry.tool.def.name, arguments: input.args }, + CallToolResultSchema, + { + resetTimeoutOnProgress: true, + signal: input.ctx.abort, + timeout: input.entry.tool.timeout, + // The MCP SDK only sends a progress token when this hook is present, enabling timeout resets. + onprogress: () => {}, + }, + ) + if (raw.isError) + throw new Error( + raw.content + .flatMap((item) => (item.type === "text" ? [item.text] : [])) + .filter((text) => text.trim()) + .join("\n\n") || "MCP tool returned an error", + ) + return raw + }) + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": input.entry.key, + "tool.call_id": input.callID, + "session.id": input.ctx.sessionID, + "message.id": input.ctx.messageID, + }, + }), + ) + yield* input.plugin.trigger( + "tool.execute.after", + { tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID, args: input.args }, + result, + ) + return result +}) + +export const CodeModeTool = Tool.define( + CODE_MODE_TOOL, + Effect.gen(function* () { + const mcp = yield* MCP.Service + const agents = yield* Agent.Service + const sessions = yield* Session.Service + const plugin = yield* Plugin.Service + + const init: Tool.DefWithoutID = { + description: DESCRIPTION, + parameters: Parameters, + execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { + if (ctx.abort.aborted) { + return { + title: CODE_MODE_TOOL, + metadata: { toolCalls: [], error: true }, + output: "Execution cancelled.", + } satisfies Tool.ExecuteResult + } + const agent = yield* agents.get(ctx.agent) + const session = yield* sessions.get(ctx.sessionID).pipe(Effect.orDie) + const ruleset = Permission.merge(agent.permission, session.permission ?? []) + const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset) + const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize) + const catalog = [...groupByServer(mcpTools, servers).values()].flat() + + const calls: CallEntry[] = [] + const attachments: Attachment[] = [] + const publish = () => + ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) + + let childCalls = 0 + const callTool = (entry: CatalogEntry) => (input: unknown) => + Effect.gen(function* () { + childCalls += 1 + const result = yield* invokeChildTool({ + plugin, + entry, + args: (input ?? {}) as Record, + callID: `${ctx.callID ?? entry.key}/${childCalls}`, + ctx, + }) + return projectMcpResult(result, (attachment: Attachment) => void attachments.push(attachment)) + }).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt + const error = Cause.squash(cause) + return Effect.fail(toolError(error instanceof Error ? error.message : String(error), error)) + }), + ) + + const runtime = CodeMode.make({ + tools: toolTree(catalog, callTool), + onToolCallStart: ({ index, name, input }) => + Effect.suspend(() => { + const shown = (() => { + if (input === null || input === undefined) return + if (typeof input === "object" && !Array.isArray(input)) { + const value = input as Record + return Object.keys(value).length > 0 ? value : undefined + } + return { input } + })() + calls[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) } + return publish() + }), + onToolCallEnd: ({ index, outcome }) => + Effect.suspend(() => { + const current = calls[index] + if (current) calls[index] = { ...current, status: outcome === "success" ? "completed" : "error" } + return publish() + }), + }) + + const abort = Effect.callback((resume) => { + if (ctx.abort.aborted) return resume(Effect.void) + const handler = () => resume(Effect.void) + ctx.abort.addEventListener("abort", handler, { once: true }) + return Effect.sync(() => ctx.abort.removeEventListener("abort", handler)) + }) + const cancelled = (): CodeMode.Result => ({ + ok: false, + error: { kind: "ExecutionFailure", message: "Execution cancelled." }, + toolCalls: calls.map((call) => ({ name: call.tool })), + }) + + const result = yield* Effect.raceFirst(runtime.execute(params.code), abort.pipe(Effect.map(cancelled))) + const logs = result.logs ?? [] + const withLogs = (text: string) => { + if (logs.length === 0) return text + return text.length > 0 ? `${text}\n\nLogs:\n${logs.join("\n")}` : `Logs:\n${logs.join("\n")}` + } + + if (!result.ok) { + if (ctx.abort.aborted) { + return { + title: CODE_MODE_TOOL, + metadata: { toolCalls: calls, error: true }, + output: "Execution cancelled.", + } satisfies Tool.ExecuteResult + } + const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint)) + return yield* Effect.fail(new Error(withLogs([result.error.message, ...hints].join("\n")))) + } + + // The interpreter validates returned values as plain JSON, so stringify cannot throw; + // it yields undefined only for a program that returns undefined. + const output = + typeof result.value === "string" + ? result.value + : (JSON.stringify(result.value, null, 2) ?? String(result.value)) + + return { + title: CODE_MODE_TOOL, + metadata: { toolCalls: calls }, + output: withLogs(output), + ...(attachments.length > 0 ? { attachments } : {}), + } satisfies Tool.ExecuteResult + }, Effect.orDie), + } + return init + }), +) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts new file mode 100644 index 0000000000000000000000000000000000000000..a92e4720c0fccbc162ae0ac0a33e9b121fa9fd5a --- /dev/null +++ b/packages/opencode/src/tool/edit.ts @@ -0,0 +1,737 @@ +// the approaches in this edit tool are sourced from +// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts +// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts +// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts + +import * as path from "path" +import { Effect, Schema, Semaphore } from "effect" +import * as Tool from "./tool" +import { LSP } from "@/lsp/lsp" +import { createTwoFilesPatch, diffLines } from "diff" +import DESCRIPTION from "./edit.txt" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Format } from "../format" +import { InstanceState } from "@/effect/instance-state" +import { Snapshot } from "@/snapshot" +import { assertExternalDirectoryEffect } from "./external-directory" +import { FSUtil } from "@opencode-ai/core/fs-util" +import * as Bom from "@/util/bom" + +function normalizeLineEndings(text: string): string { + return text.replaceAll("\r\n", "\n") +} + +function detectLineEnding(text: string): "\n" | "\r\n" { + return text.includes("\r\n") ? "\r\n" : "\n" +} + +function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string { + if (ending === "\n") return text + return text.replaceAll("\n", "\r\n") +} + +const locks = new Map() + +function lock(filePath: string) { + const resolvedFilePath = FSUtil.resolve(filePath) + const hit = locks.get(resolvedFilePath) + if (hit) return hit + + const next = Semaphore.makeUnsafe(1) + locks.set(resolvedFilePath, next) + return next +} + +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), + oldString: Schema.String.annotate({ description: "The text to replace" }), + newString: Schema.String.annotate({ + description: "The text to replace it with (must be different from oldString)", + }), + replaceAll: Schema.optional(Schema.Boolean).annotate({ + description: "Replace all occurrences of oldString (default false)", + }), +}) + +export const EditTool = Tool.define( + "edit", + Effect.gen(function* () { + const lsp = yield* LSP.Service + const afs = yield* FSUtil.Service + const format = yield* Format.Service + const events = yield* EventV2Bridge.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + if (!params.filePath) { + throw new Error("filePath is required") + } + + if (params.oldString === params.newString) { + throw new Error("No changes to apply: oldString and newString are identical.") + } + + const instance = yield* InstanceState.context + const filePath = path.isAbsolute(params.filePath) + ? params.filePath + : path.join(instance.directory, params.filePath) + yield* assertExternalDirectoryEffect(ctx, filePath) + + let diff = "" + let contentOld = "" + let contentNew = "" + yield* lock(filePath).withPermits(1)( + Effect.gen(function* () { + if (params.oldString === "") { + const existed = yield* afs.existsSafe(filePath) + if (existed) { + throw new Error( + "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.", + ) + } + const next = Bom.split(params.newString) + const desiredBom = next.bom + contentOld = "" + contentNew = next.text + diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(instance.worktree, filePath)], + always: ["*"], + metadata: { + filepath: filePath, + diff, + }, + }) + yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom)) + if (yield* format.file(filePath)) { + contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) + } + yield* events.publish(FileSystem.Event.Edited, { file: filePath }) + yield* events.publish(Watcher.Event.Updated, { + file: filePath, + event: "add", + }) + return + } + + const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) throw new Error(`File ${filePath} not found`) + if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`) + const source = yield* Bom.readFile(afs, filePath) + contentOld = source.text + + const ending = detectLineEnding(contentOld) + const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) + const replacement = convertToLineEnding(normalizeLineEndings(params.newString), ending) + + const next = Bom.split(replace(contentOld, old, replacement, params.replaceAll)) + const desiredBom = source.bom || next.bom + contentNew = next.text + + diff = trimDiff( + createTwoFilesPatch( + filePath, + filePath, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew), + ), + ) + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(instance.worktree, filePath)], + always: ["*"], + metadata: { + filepath: filePath, + diff, + }, + }) + + yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom)) + if (yield* format.file(filePath)) { + contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) + } + yield* events.publish(FileSystem.Event.Edited, { file: filePath }) + yield* events.publish(Watcher.Event.Updated, { + file: filePath, + event: "change", + }) + diff = trimDiff( + createTwoFilesPatch( + filePath, + filePath, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew), + ), + ) + }).pipe(Effect.orDie), + ) + + let additions = 0 + let deletions = 0 + for (const change of diffLines(contentOld, contentNew)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + const filediff: Snapshot.FileDiff = { + file: filePath, + patch: diff, + additions, + deletions, + } + + yield* ctx.metadata({ + metadata: { + diff, + filediff, + diagnostics: {}, + }, + }) + + let output = "Edit applied successfully." + yield* lsp.touchFile(filePath, "document") + const diagnostics = yield* lsp.diagnostics() + const normalizedFilePath = FSUtil.normalizePath(filePath) + const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? []) + if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}` + + return { + metadata: { + diagnostics, + diff, + filediff, + }, + title: `${path.relative(instance.worktree, filePath)}`, + output, + } + }), + } + }), +) + +export type Replacer = (content: string, find: string) => Generator + +// Similarity thresholds for block anchor fallback matching +const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.65 +const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.65 + +/** + * Levenshtein distance algorithm implementation + */ +function levenshtein(a: string, b: string): number { + // Handle empty strings + if (a === "" || b === "") { + return Math.max(a.length, b.length) + } + const matrix = Array.from({ length: a.length + 1 }, (_, i) => + Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), + ) + + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost) + } + } + return matrix[a.length][b.length] +} + +export const SimpleReplacer: Replacer = function* (_content, find) { + yield find +} + +export const LineTrimmedReplacer: Replacer = function* (content, find) { + const originalLines = content.split("\n") + const searchLines = find.split("\n") + + if (searchLines[searchLines.length - 1] === "") { + searchLines.pop() + } + + for (let i = 0; i <= originalLines.length - searchLines.length; i++) { + let matches = true + + for (let j = 0; j < searchLines.length; j++) { + const originalTrimmed = originalLines[i + j].trim() + const searchTrimmed = searchLines[j].trim() + + if (originalTrimmed !== searchTrimmed) { + matches = false + break + } + } + + if (matches) { + let matchStartIndex = 0 + for (let k = 0; k < i; k++) { + matchStartIndex += originalLines[k].length + 1 + } + + let matchEndIndex = matchStartIndex + for (let k = 0; k < searchLines.length; k++) { + matchEndIndex += originalLines[i + k].length + if (k < searchLines.length - 1) { + matchEndIndex += 1 // Add newline character except for the last line + } + } + + yield content.substring(matchStartIndex, matchEndIndex) + } + } +} + +export const BlockAnchorReplacer: Replacer = function* (content, find) { + const originalLines = content.split("\n") + const searchLines = find.split("\n") + + if (searchLines.length < 3) { + return + } + + if (searchLines[searchLines.length - 1] === "") { + searchLines.pop() + } + + const firstLineSearch = searchLines[0].trim() + const lastLineSearch = searchLines[searchLines.length - 1].trim() + const searchBlockSize = searchLines.length + const maxLineDelta = Math.max(1, Math.floor(searchBlockSize * 0.25)) + + // Collect all candidate positions where both anchors match + const candidates: Array<{ startLine: number; endLine: number }> = [] + for (let i = 0; i < originalLines.length; i++) { + if (originalLines[i].trim() !== firstLineSearch) { + continue + } + + // Look for the matching last line after this first line + for (let j = i + 2; j < originalLines.length; j++) { + if (originalLines[j].trim() === lastLineSearch) { + const actualBlockSize = j - i + 1 + if (Math.abs(actualBlockSize - searchBlockSize) <= maxLineDelta) { + candidates.push({ startLine: i, endLine: j }) + } + break // Only match the first occurrence of the last line + } + } + } + + // Return immediately if no candidates + if (candidates.length === 0) { + return + } + + // Handle single candidate scenario (using relaxed threshold) + if (candidates.length === 1) { + const { startLine, endLine } = candidates[0] + const actualBlockSize = endLine - startLine + 1 + + let similarity = 0 + const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only + + if (linesToCheck > 0) { + for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) { + const originalLine = originalLines[startLine + j].trim() + const searchLine = searchLines[j].trim() + const maxLen = Math.max(originalLine.length, searchLine.length) + if (maxLen === 0) { + continue + } + const distance = levenshtein(originalLine, searchLine) + similarity += (1 - distance / maxLen) / linesToCheck + + // Exit early when threshold is reached + if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) { + break + } + } + } else { + // No middle lines to compare, just accept based on anchors + similarity = 1.0 + } + + if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) { + let matchStartIndex = 0 + for (let k = 0; k < startLine; k++) { + matchStartIndex += originalLines[k].length + 1 + } + let matchEndIndex = matchStartIndex + for (let k = startLine; k <= endLine; k++) { + matchEndIndex += originalLines[k].length + if (k < endLine) { + matchEndIndex += 1 // Add newline character except for the last line + } + } + yield content.substring(matchStartIndex, matchEndIndex) + } + return + } + + // Calculate similarity for multiple candidates + let bestMatch: { startLine: number; endLine: number } | null = null + let maxSimilarity = -1 + + for (const candidate of candidates) { + const { startLine, endLine } = candidate + const actualBlockSize = endLine - startLine + 1 + + let similarity = 0 + const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only + + if (linesToCheck > 0) { + for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) { + const originalLine = originalLines[startLine + j].trim() + const searchLine = searchLines[j].trim() + const maxLen = Math.max(originalLine.length, searchLine.length) + if (maxLen === 0) { + continue + } + const distance = levenshtein(originalLine, searchLine) + similarity += 1 - distance / maxLen + } + similarity /= linesToCheck // Average similarity + } else { + // No middle lines to compare, just accept based on anchors + similarity = 1.0 + } + + if (similarity > maxSimilarity) { + maxSimilarity = similarity + bestMatch = candidate + } + } + + // Threshold judgment + if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) { + const { startLine, endLine } = bestMatch + let matchStartIndex = 0 + for (let k = 0; k < startLine; k++) { + matchStartIndex += originalLines[k].length + 1 + } + let matchEndIndex = matchStartIndex + for (let k = startLine; k <= endLine; k++) { + matchEndIndex += originalLines[k].length + if (k < endLine) { + matchEndIndex += 1 + } + } + yield content.substring(matchStartIndex, matchEndIndex) + } +} + +export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) { + const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim() + const normalizedFind = normalizeWhitespace(find) + + // Handle single line matches + const lines = content.split("\n") + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (normalizeWhitespace(line) === normalizedFind) { + yield line + } else { + // Only check for substring matches if the full line doesn't match + const normalizedLine = normalizeWhitespace(line) + if (normalizedLine.includes(normalizedFind)) { + // Find the actual substring in the original line that matches + const words = find.trim().split(/\s+/) + if (words.length > 0) { + const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+") + try { + const regex = new RegExp(pattern) + const match = line.match(regex) + if (match) { + yield match[0] + } + } catch { + // Invalid regex pattern, skip + } + } + } + } + } + + // Handle multi-line matches + const findLines = find.split("\n") + if (findLines.length > 1) { + for (let i = 0; i <= lines.length - findLines.length; i++) { + const block = lines.slice(i, i + findLines.length) + if (normalizeWhitespace(block.join("\n")) === normalizedFind) { + yield block.join("\n") + } + } + } +} + +export const IndentationFlexibleReplacer: Replacer = function* (content, find) { + const removeIndentation = (text: string) => { + const lines = text.split("\n") + const nonEmptyLines = lines.filter((line) => line.trim().length > 0) + if (nonEmptyLines.length === 0) return text + + const minIndent = Math.min( + ...nonEmptyLines.map((line) => { + const match = line.match(/^(\s*)/) + return match ? match[1].length : 0 + }), + ) + + return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n") + } + + const normalizedFind = removeIndentation(find) + const contentLines = content.split("\n") + const findLines = find.split("\n") + + for (let i = 0; i <= contentLines.length - findLines.length; i++) { + const block = contentLines.slice(i, i + findLines.length).join("\n") + if (removeIndentation(block) === normalizedFind) { + yield block + } + } +} + +export const EscapeNormalizedReplacer: Replacer = function* (content, find) { + const unescapeString = (str: string): string => { + return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => { + switch (capturedChar) { + case "n": + return "\n" + case "t": + return "\t" + case "r": + return "\r" + case "'": + return "'" + case '"': + return '"' + case "`": + return "`" + case "\\": + return "\\" + case "\n": + return "\n" + case "$": + return "$" + default: + return match + } + }) + } + + const unescapedFind = unescapeString(find) + + // Try direct match with unescaped find string + if (content.includes(unescapedFind)) { + yield unescapedFind + } + + // Also try finding escaped versions in content that match unescaped find + const lines = content.split("\n") + const findLines = unescapedFind.split("\n") + + for (let i = 0; i <= lines.length - findLines.length; i++) { + const block = lines.slice(i, i + findLines.length).join("\n") + const unescapedBlock = unescapeString(block) + + if (unescapedBlock === unescapedFind) { + yield block + } + } +} + +export const MultiOccurrenceReplacer: Replacer = function* (content, find) { + // This replacer yields all exact matches, allowing the replace function + // to handle multiple occurrences based on replaceAll parameter + let startIndex = 0 + + while (true) { + const index = content.indexOf(find, startIndex) + if (index === -1) break + + yield find + startIndex = index + find.length + } +} + +export const TrimmedBoundaryReplacer: Replacer = function* (content, find) { + const trimmedFind = find.trim() + + if (trimmedFind === find) { + // Already trimmed, no point in trying + return + } + + // Try to find the trimmed version + if (content.includes(trimmedFind)) { + yield trimmedFind + } + + // Also try finding blocks where trimmed content matches + const lines = content.split("\n") + const findLines = find.split("\n") + + for (let i = 0; i <= lines.length - findLines.length; i++) { + const block = lines.slice(i, i + findLines.length).join("\n") + + if (block.trim() === trimmedFind) { + yield block + } + } +} + +export const ContextAwareReplacer: Replacer = function* (content, find) { + const findLines = find.split("\n") + if (findLines.length < 3) { + // Need at least 3 lines to have meaningful context + return + } + + // Remove trailing empty line if present + if (findLines[findLines.length - 1] === "") { + findLines.pop() + } + + const contentLines = content.split("\n") + + // Extract first and last lines as context anchors + const firstLine = findLines[0].trim() + const lastLine = findLines[findLines.length - 1].trim() + + // Find blocks that start and end with the context anchors + for (let i = 0; i < contentLines.length; i++) { + if (contentLines[i].trim() !== firstLine) continue + + // Look for the matching last line + for (let j = i + 2; j < contentLines.length; j++) { + if (contentLines[j].trim() === lastLine) { + // Found a potential context block + const blockLines = contentLines.slice(i, j + 1) + const block = blockLines.join("\n") + + // Check if the middle content has reasonable similarity + // (simple heuristic: at least 50% of non-empty lines should match when trimmed) + if (blockLines.length === findLines.length) { + let matchingLines = 0 + let totalNonEmptyLines = 0 + + for (let k = 1; k < blockLines.length - 1; k++) { + const blockLine = blockLines[k].trim() + const findLine = findLines[k].trim() + + if (blockLine.length > 0 || findLine.length > 0) { + totalNonEmptyLines++ + if (blockLine === findLine) { + matchingLines++ + } + } + } + + if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) { + yield block + break // Only match the first occurrence + } + } + break + } + } + } +} + +export function trimDiff(diff: string): string { + const lines = diff.split("\n") + const contentLines = lines.filter( + (line) => + (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && + !line.startsWith("---") && + !line.startsWith("+++"), + ) + + if (contentLines.length === 0) return diff + + let min = Infinity + for (const line of contentLines) { + const content = line.slice(1) + if (content.trim().length > 0) { + const match = content.match(/^(\s*)/) + if (match) min = Math.min(min, match[1].length) + } + } + if (min === Infinity || min === 0) return diff + const trimmedLines = lines.map((line) => { + if ( + (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && + !line.startsWith("---") && + !line.startsWith("+++") + ) { + const prefix = line[0] + const content = line.slice(1) + return prefix + content.slice(min) + } + return line + }) + + return trimmedLines.join("\n") +} + +export function replace(content: string, oldString: string, newString: string, replaceAll = false): string { + if (oldString === newString) { + throw new Error("No changes to apply: oldString and newString are identical.") + } + if (oldString === "") { + throw new Error( + "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.", + ) + } + + let notFound = true + + for (const replacer of [ + SimpleReplacer, + LineTrimmedReplacer, + BlockAnchorReplacer, + WhitespaceNormalizedReplacer, + IndentationFlexibleReplacer, + EscapeNormalizedReplacer, + TrimmedBoundaryReplacer, + ContextAwareReplacer, + MultiOccurrenceReplacer, + ]) { + for (const search of replacer(content, oldString)) { + const index = content.indexOf(search) + if (index === -1) continue + notFound = false + if (isDisproportionateMatch(search, oldString)) { + throw new Error( + "Refusing replacement because the matched span is much larger than oldString. Re-read the file and provide the full exact oldString for the intended replacement.", + ) + } + if (replaceAll) { + return content.replaceAll(search, newString) + } + const lastIndex = content.lastIndexOf(search) + if (index !== lastIndex) continue + return content.substring(0, index) + newString + content.substring(index + search.length) + } + } + + if (notFound) { + throw new Error( + "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.", + ) + } + throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.") +} + +function isDisproportionateMatch(search: string, oldString: string) { + const oldLines = oldString.split("\n").length + const searchLines = search.split("\n").length + if (searchLines >= Math.max(oldLines + 3, oldLines * 2)) return true + if (oldLines === 1) return false + return search.trim().length > Math.max(oldString.trim().length + 500, oldString.trim().length * 4) +} diff --git a/packages/opencode/src/tool/edit.txt b/packages/opencode/src/tool/edit.txt new file mode 100644 index 0000000000000000000000000000000000000000..618fd5ad1ef8cc0c8a091ea502eac77e2e839954 --- /dev/null +++ b/packages/opencode/src/tool/edit.txt @@ -0,0 +1,10 @@ +Performs exact string replacements in files. + +Usage: +- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. +- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content". +- The edit will FAIL if `oldString` is found multiple times in the file with an error "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match." Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`. +- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts new file mode 100644 index 0000000000000000000000000000000000000000..4372db82d89ec76d5fbfc0bd1b272eca96bd492b --- /dev/null +++ b/packages/opencode/src/tool/external-directory.ts @@ -0,0 +1,49 @@ +import path from "path" +import { Effect } from "effect" +import { InstanceState } from "@/effect/instance-state" +import type * as Tool from "./tool" +import { containsPath } from "../project/instance-context" +import { FSUtil } from "@opencode-ai/core/fs-util" + +type Kind = "file" | "directory" + +type Options = { + bypass?: boolean + kind?: Kind +} + +export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* ( + ctx: Tool.Context, + target?: string, + options?: Options, +) { + if (!target) return false + + if (options?.bypass) return false + + const ins = yield* InstanceState.context + const full = process.platform === "win32" ? FSUtil.normalizePath(target) : target + if (containsPath(full, ins)) return false + + const kind = options?.kind ?? "file" + const dir = kind === "directory" ? full : path.dirname(full) + const glob = + process.platform === "win32" + ? FSUtil.normalizePathPattern(path.join(dir, "*")) + : path.join(dir, "*").replaceAll("\\", "/") + + yield* ctx.ask({ + permission: "external_directory", + patterns: [glob], + always: [glob], + metadata: { + filepath: full, + parentDir: dir, + }, + }) + return true +}) + +export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) { + return Effect.runPromise(assertExternalDirectoryEffect(ctx, target, options)) +} diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts new file mode 100644 index 0000000000000000000000000000000000000000..40d3a27d3cba09911ea247ecaa35cfadef41701c --- /dev/null +++ b/packages/opencode/src/tool/glob.ts @@ -0,0 +1,76 @@ +import path from "path" +import { Effect, Schema } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { assertExternalDirectoryEffect } from "./external-directory" +import DESCRIPTION from "./glob.txt" +import * as Tool from "./tool" + +export const Parameters = Schema.Struct({ + pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }), + path: Schema.optional(Schema.String).annotate({ + description: `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, + }), +}) + +export const GlobTool = Tool.define( + "glob", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const ripgrep = yield* Ripgrep.Service + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) => + Effect.gen(function* () { + const ins = yield* InstanceState.context + yield* ctx.ask({ + permission: "glob", + patterns: [params.pattern], + always: ["*"], + metadata: { + pattern: params.pattern, + path: params.path, + }, + }) + + let search = params.path ?? ins.directory + search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search) + const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (info?.type === "File") { + throw new Error(`glob path must be a directory: ${search}`) + } + yield* assertExternalDirectoryEffect(ctx, search, { + bypass: false, + kind: "directory", + }) + + const limit = 100 + const files = yield* ripgrep.glob({ cwd: search, pattern: params.pattern, limit }) + const truncated = files.length === limit + + const output = [] + if (files.length === 0) output.push("No files found") + if (files.length > 0) { + output.push(...files.map((file) => path.resolve(search, file.path))) + if (truncated) { + output.push("") + output.push( + `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`, + ) + } + } + + return { + title: path.relative(ins.worktree, search), + metadata: { + count: files.length, + truncated, + }, + output: output.join("\n"), + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/glob.txt b/packages/opencode/src/tool/glob.txt new file mode 100644 index 0000000000000000000000000000000000000000..9c01f3d50f9a7d31d6514399fa06fbefac337ba9 --- /dev/null +++ b/packages/opencode/src/tool/glob.txt @@ -0,0 +1,6 @@ +- Fast file pattern matching tool that works with any codebase size +- Supports glob patterns like "**/*.js" or "src/**/*.ts" +- Returns matching file paths +- Use this tool when you need to find files by name patterns +- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead +- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ea67124a6f9e56ae66bbafe30c3bcf40e9fe9f8 --- /dev/null +++ b/packages/opencode/src/tool/grep.ts @@ -0,0 +1,115 @@ +import path from "path" +import { Effect, Schema } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { assertExternalDirectoryEffect } from "./external-directory" +import DESCRIPTION from "./grep.txt" +import * as Tool from "./tool" + +export const Parameters = Schema.Struct({ + pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }), + path: Schema.optional(Schema.String).annotate({ + description: "The directory to search in. Defaults to the current working directory.", + }), + include: Schema.optional(Schema.String).annotate({ + description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")', + }), +}) + +export const GrepTool = Tool.define( + "grep", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const ripgrep = yield* Ripgrep.Service + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) => + Effect.gen(function* () { + const empty = { + title: params.pattern, + metadata: { matches: 0, truncated: false }, + output: "No files found", + } + if (!params.pattern) { + throw new Error("pattern is required") + } + + yield* ctx.ask({ + permission: "grep", + patterns: [params.pattern], + always: ["*"], + metadata: { + pattern: params.pattern, + path: params.path, + include: params.include, + }, + }) + + const ins = yield* InstanceState.context + const requested = path.isAbsolute(params.path ?? ins.directory) + ? (params.path ?? ins.directory) + : path.join(ins.directory, params.path ?? ".") + const requestedInfo = yield* fs.stat(requested).pipe(Effect.catch(() => Effect.succeed(undefined))) + yield* assertExternalDirectoryEffect(ctx, requested, { + bypass: false, + kind: requestedInfo?.type === "Directory" ? "directory" : "file", + }) + + const search = FSUtil.resolve(requested) + const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) + const cwd = info?.type === "Directory" ? search : path.dirname(search) + const result = yield* ripgrep.grep({ + cwd, + pattern: params.pattern, + include: params.include, + limit: 100, + }) + if (result.length === 0) return empty + + const rows = result.map((item) => ({ + path: path.resolve( + requestedInfo?.type === "Directory" ? requested : path.dirname(requested), + item.entry.path, + ), + line: item.line, + text: item.text, + })) + + const limit = 100 + const truncated = rows.length === limit + const final = rows + if (final.length === 0) return empty + + const total = rows.length + const hasMore = truncated || result.length === limit + const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`] + + let current = "" + for (const match of final) { + if (current !== match.path) { + if (current !== "") output.push("") + current = match.path + output.push(`${match.path}:`) + } + output.push(` Line ${match.line}: ${match.text}`) + } + + if (truncated) { + output.push("") + output.push("(Results truncated. Consider using a more specific path or pattern.)") + } + + return { + title: params.pattern, + metadata: { + matches: total, + truncated, + }, + output: output.join("\n"), + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/grep.txt b/packages/opencode/src/tool/grep.txt new file mode 100644 index 0000000000000000000000000000000000000000..c075da1e6b4686c4def8fc55956f3b748da90e9d --- /dev/null +++ b/packages/opencode/src/tool/grep.txt @@ -0,0 +1,8 @@ +- Fast content search tool that works with any codebase size +- Searches file contents using regular expressions +- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.) +- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}") +- Returns file paths and line numbers with matching lines +- Use this tool when you need to find files containing specific patterns +- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. +- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead diff --git a/packages/opencode/src/tool/invalid.ts b/packages/opencode/src/tool/invalid.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8d145d0be23da1b505610296982bc2c60fc9ecc --- /dev/null +++ b/packages/opencode/src/tool/invalid.ts @@ -0,0 +1,21 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" + +export const Parameters = Schema.Struct({ + tool: Schema.String, + error: Schema.String, +}) + +export const InvalidTool = Tool.define( + "invalid", + Effect.succeed({ + description: "Do not use", + parameters: Parameters, + execute: (params: { tool: string; error: string }) => + Effect.succeed({ + title: "Invalid Tool", + output: `The arguments provided to the tool are invalid: ${params.error}`, + metadata: {}, + }), + }), +) diff --git a/packages/opencode/src/tool/json-schema.ts b/packages/opencode/src/tool/json-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..edb43e11ca893dc9cc8b8e8c37d4b0fe7736ffbe --- /dev/null +++ b/packages/opencode/src/tool/json-schema.ts @@ -0,0 +1,164 @@ +import type { JSONSchema7 } from "@ai-sdk/provider" +import { JsonSchema, Schema } from "effect" +import type * as Tool from "./tool" + +type JsonObject = Record +const cache = new WeakMap() + +export function fromSchema(schema: Schema.Top): JSONSchema7 { + const cached = cache.get(schema) + if (cached) return cached + + const document = Schema.toJsonSchemaDocument(schema, { additionalProperties: true }) + const result = normalize({ + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, + ...document.schema, + ...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}), + }) + const inlined = dropDefinitionsIfResolved(inlineLocalReferences(result)) + if (!isJsonSchema(inlined)) throw new Error("tool JSON Schema helper produced a non-schema value") + cache.set(schema, inlined) + return inlined +} + +export function fromTool(tool: Tool.Def): JSONSchema7 { + return tool.jsonSchema ?? fromSchema(tool.parameters as Schema.Top) +} + +function normalize(value: unknown, options: { stripNull?: boolean } = {}): unknown { + if (Array.isArray(value)) return value.map((item) => normalize(item)) + if (!isRecord(value)) return value + + const required = Array.isArray(value.required) + ? new Set(value.required.filter((item) => typeof item === "string")) + : undefined + const schema = Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + key === "properties" && isRecord(item) + ? Object.fromEntries( + Object.entries(item).map(([name, property]) => [ + name, + normalize(property, { stripNull: !required?.has(name) }), + ]), + ) + : normalize(item), + ]), + ) + + if (schema.additionalProperties === true) delete schema.additionalProperties + + if (options.stripNull && Array.isArray(schema.anyOf)) { + const withoutNull = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null") + if (withoutNull.length !== schema.anyOf.length) return normalize({ ...schema, anyOf: withoutNull }) + } + + if (Array.isArray(schema.anyOf)) { + const withoutNull = schema.anyOf + const number = withoutNull.find((item) => isRecord(item) && item.type === "number") + const nonFinite = withoutNull.filter( + (item) => isRecord(item) && Array.isArray(item.enum) && item.enum.every((entry) => isNonFiniteNumber(entry)), + ) + if (number && nonFinite.length === withoutNull.length - 1) { + const { anyOf: _, ...rest } = schema + return normalize({ ...number, ...rest }) + } + + if (isEmptyStructUnion(withoutNull)) { + const { anyOf: _, ...rest } = schema + return normalize({ type: "object", properties: {}, ...rest }) + } + + if (withoutNull.length === 1 && isRecord(withoutNull[0])) { + const { anyOf: _, ...rest } = schema + return normalize({ ...withoutNull[0], ...rest }) + } + } + + if (Array.isArray(schema.allOf) && schema.allOf.every(isRecord) && canFlattenAllOf(schema.allOf, schema)) { + const { allOf, ...rest } = schema + return normalize({ ...Object.assign({}, ...allOf), ...rest }) + } + + if (schema.type === "integer" && schema.maximum === undefined) { + return { minimum: Number.MIN_SAFE_INTEGER, ...schema, maximum: Number.MAX_SAFE_INTEGER } + } + + return schema +} + +function isRecord(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isJsonSchema(value: unknown): value is JSONSchema7 { + return typeof value === "boolean" || isRecord(value) +} + +function isNonFiniteNumber(value: unknown) { + return value === "NaN" || value === "Infinity" || value === "-Infinity" +} + +function isEmptyStructUnion(items: unknown[]) { + return ( + items.length === 2 && + items.some((item) => isRecord(item) && item.type === "object" && item.properties === undefined) && + items.some((item) => isRecord(item) && item.type === "array" && item.items === undefined) + ) +} + +function canFlattenAllOf(allOf: JsonObject[], parent: JsonObject) { + const keys = new Set(Object.keys(parent).filter((key) => key !== "allOf")) + return allOf.every((item) => + Object.keys(item).every((key) => { + if (keys.has(key)) return false + keys.add(key) + return true + }), + ) +} + +function inlineLocalReferences(value: unknown, definitions?: JsonObject, seen = new Set()): unknown { + if (Array.isArray(value)) return value.map((item) => inlineLocalReferences(item, definitions, seen)) + if (!isRecord(value)) return value + + const localDefinitions = definitions ?? (isRecord(value.$defs) ? value.$defs : undefined) + if (typeof value.$ref === "string" && localDefinitions) { + const name = value.$ref.match(/^#\/\$defs\/(.+)$/)?.[1] ?? value.$ref.match(/^#\/definitions\/(.+)$/)?.[1] + if (name && !seen.has(name)) { + const target = localDefinitions[name] + if (target) { + const { $ref: _, ...rest } = value + return inlineLocalReferences( + { ...(isRecord(target) ? target : {}), ...rest }, + localDefinitions, + new Set(seen).add(name), + ) + } + } + } + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, inlineLocalReferences(item, localDefinitions, seen)]), + ) +} + +function dropDefinitionsIfResolved(value: unknown): unknown { + if (!isRecord(value) || hasLocalReference(value)) return value + const { $defs: _, definitions: __, ...rest } = value + return rest +} + +function hasLocalReference(value: unknown): boolean { + if (Array.isArray(value)) return value.some(hasLocalReference) + if (!isRecord(value)) return false + if ( + typeof value.$ref === "string" && + (value.$ref.startsWith("#/$defs/") || value.$ref.startsWith("#/definitions/")) + ) { + return true + } + return Object.values(value).some(hasLocalReference) +} + +export * as ToolJsonSchema from "./json-schema" diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts new file mode 100644 index 0000000000000000000000000000000000000000..a605cea749d5d9aaf02c1fdcad4f69a65de81bbf --- /dev/null +++ b/packages/opencode/src/tool/lsp.ts @@ -0,0 +1,113 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import path from "path" +import { LSP } from "@/lsp/lsp" +import DESCRIPTION from "./lsp.txt" +import { InstanceState } from "@/effect/instance-state" +import { pathToFileURL } from "url" +import { assertExternalDirectoryEffect } from "./external-directory" +import { FSUtil } from "@opencode-ai/core/fs-util" + +const operations = [ + "goToDefinition", + "findReferences", + "hover", + "documentSymbol", + "workspaceSymbol", + "goToImplementation", + "prepareCallHierarchy", + "incomingCalls", + "outgoingCalls", +] as const + +export const Parameters = Schema.Struct({ + operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }), + filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }), + line: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({ + description: "The line number (1-based, as shown in editors)", + }), + character: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({ + description: "The character offset (1-based, as shown in editors)", + }), + query: Schema.optional(Schema.String).annotate({ + description: "Search query for workspaceSymbol. Empty string requests all symbols.", + }), +}) + +export const LspTool = Tool.define( + "lsp", + Effect.gen(function* () { + const lsp = yield* LSP.Service + const fs = yield* FSUtil.Service + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const instance = yield* InstanceState.context + const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(instance.directory, args.filePath) + yield* assertExternalDirectoryEffect(ctx, file) + const meta = + args.operation === "workspaceSymbol" + ? { operation: args.operation } + : args.operation === "documentSymbol" + ? { operation: args.operation, filePath: file } + : { operation: args.operation, filePath: file, line: args.line, character: args.character } + yield* ctx.ask({ + permission: "lsp", + patterns: ["*"], + always: ["*"], + metadata: meta, + }) + + const uri = pathToFileURL(file).href + const position = { file, line: args.line - 1, character: args.character - 1 } + const relPath = path.relative(instance.worktree, file) + const detail = + args.operation === "workspaceSymbol" + ? "" + : args.operation === "documentSymbol" + ? relPath + : `${relPath}:${args.line}:${args.character}` + const title = detail ? `${args.operation} ${detail}` : args.operation + + const exists = yield* fs.existsSafe(file) + if (!exists) throw new Error(`File not found: ${file}`) + + const available = yield* lsp.hasClients(file) + if (!available) throw new Error("No LSP server available for this file type.") + + yield* lsp.touchFile(file, "document") + + const result: unknown[] = yield* (() => { + switch (args.operation) { + case "goToDefinition": + return lsp.definition(position) + case "findReferences": + return lsp.references(position) + case "hover": + return lsp.hover(position) + case "documentSymbol": + return lsp.documentSymbol(uri) + case "workspaceSymbol": + return lsp.workspaceSymbol(args.query ?? "") + case "goToImplementation": + return lsp.implementation(position) + case "prepareCallHierarchy": + return lsp.prepareCallHierarchy(position) + case "incomingCalls": + return lsp.incomingCalls(position) + case "outgoingCalls": + return lsp.outgoingCalls(position) + } + })() + + return { + title, + metadata: { result }, + output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2), + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/lsp.txt b/packages/opencode/src/tool/lsp.txt new file mode 100644 index 0000000000000000000000000000000000000000..85db65c1739edc7fbc6b1c7cd16ed3832379e61d --- /dev/null +++ b/packages/opencode/src/tool/lsp.txt @@ -0,0 +1,24 @@ +Interact with Language Server Protocol (LSP) servers to get code intelligence features. + +Supported operations: +- goToDefinition: Find where a symbol is defined +- findReferences: Find all references to a symbol +- hover: Get hover information (documentation, type info) for a symbol +- documentSymbol: Get all symbols (functions, classes, variables) in a document +- workspaceSymbol: List project-wide symbols matching a query string +- goToImplementation: Find implementations of an interface or abstract method +- prepareCallHierarchy: Get call hierarchy item at a position (functions/methods) +- incomingCalls: Find all functions/methods that call the function at a position +- outgoingCalls: Find all functions/methods called by the function at a position + +All operations require: +- filePath: The file to operate on +- line: The line number (1-based, as shown in editors) +- character: The character offset (1-based, as shown in editors) + +workspaceSymbol also accepts: +- query: A query string to filter symbols by. Empty string requests all symbols. + +For workspaceSymbol, filePath is not sent in the LSP workspace/symbol request. It is used by opencode to select and start the matching LSP server. + +Note: LSP servers must be configured for the file type. If no server is available, an error will be returned. diff --git a/packages/opencode/src/tool/mcp-websearch.ts b/packages/opencode/src/tool/mcp-websearch.ts new file mode 100644 index 0000000000000000000000000000000000000000..208924cba5aa97aeeed69e624b995ebf292cbe3c --- /dev/null +++ b/packages/opencode/src/tool/mcp-websearch.ts @@ -0,0 +1,96 @@ +import { Duration, Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" + +export const EXA_URL = process.env.EXA_API_KEY + ? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}` + : "https://mcp.exa.ai/mcp" +export const PARALLEL_URL = "https://search.parallel.ai/mcp" + +const McpResult = Schema.Struct({ + result: Schema.Struct({ + content: Schema.Array( + Schema.Struct({ + type: Schema.String, + text: Schema.String, + }), + ), + }), +}) + +const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult)) + +const parsePayload = (payload: string) => + Effect.gen(function* () { + const trimmed = payload.trim() + if (!trimmed.startsWith("{")) return undefined + const data = yield* decode(trimmed) + return data.result.content.find((item) => item.text)?.text + }) + +export const parseResponse = Effect.fn("McpWebSearch.parseResponse")(function* (body: string) { + const trimmed = body.trim() + const direct = trimmed ? yield* parsePayload(trimmed) : undefined + if (direct) return direct + + for (const line of body.split("\n")) { + if (!line.startsWith("data: ")) continue + const data = yield* parsePayload(line.substring(6)) + if (data) return data + } + return undefined +}) + +export const SearchArgs = Schema.Struct({ + query: Schema.String, + type: Schema.String, + numResults: Schema.Number, + livecrawl: Schema.String, + contextMaxCharacters: Schema.optional(Schema.Number), +}) + +export const ParallelSearchArgs = Schema.Struct({ + objective: Schema.String, + search_queries: Schema.Array(Schema.String), + session_id: Schema.optional(Schema.String), + model_name: Schema.optional(Schema.String), +}) + +const McpRequest = (args: Schema.Struct) => + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.Literal(1), + method: Schema.Literal("tools/call"), + params: Schema.Struct({ + name: Schema.String, + arguments: args, + }), + }) + +export const call = ( + http: HttpClient.HttpClient, + url: string, + tool: string, + args: Schema.Struct, + value: Schema.Struct.Type, + timeout: Duration.Input, + headers?: Record, +) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.post(url).pipe( + HttpClientRequest.accept("application/json, text/event-stream"), + HttpClientRequest.setHeaders(headers ?? {}), + HttpClientRequest.schemaBodyJson(McpRequest(args))({ + jsonrpc: "2.0" as const, + id: 1 as const, + method: "tools/call" as const, + params: { name: tool, arguments: value }, + }), + ) + const response = yield* HttpClient.filterStatusOk(http) + .execute(request) + .pipe( + Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }), + ) + const body = yield* response.text + return yield* parseResponse(body) + }) diff --git a/packages/opencode/src/tool/plan-enter.txt b/packages/opencode/src/tool/plan-enter.txt new file mode 100644 index 0000000000000000000000000000000000000000..2e6a69f1f58997dbac87d1fc77686502f9e02dbe --- /dev/null +++ b/packages/opencode/src/tool/plan-enter.txt @@ -0,0 +1,14 @@ +Use this tool to suggest switching to plan agent when the user's request would benefit from planning before implementation. + +If they explicitly mention wanting to create a plan ALWAYS call this tool first. + +This tool will ask the user if they want to switch to plan agent. + +Call this tool when: +- The user's request is complex and would benefit from planning first +- You want to research and design before making changes +- The task involves multiple files or significant architectural decisions + +Do NOT call this tool: +- For simple, straightforward tasks +- When the user explicitly wants immediate implementation diff --git a/packages/opencode/src/tool/plan-exit.txt b/packages/opencode/src/tool/plan-exit.txt new file mode 100644 index 0000000000000000000000000000000000000000..988821de3b9ac29cabcfcc763432fd9d40ddc0c3 --- /dev/null +++ b/packages/opencode/src/tool/plan-exit.txt @@ -0,0 +1,13 @@ +Use this tool when you have completed the planning phase and are ready to exit plan agent. + +This tool will ask the user if they want to switch to build agent to start implementing the plan. + +Call this tool: +- After you have written a complete plan to the plan file +- After you have clarified any questions with the user +- When you are confident the plan is ready for implementation + +Do NOT call this tool: +- Before you have created or finalized the plan +- If you still have unanswered questions about the implementation +- If the user has indicated they want to continue planning diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts new file mode 100644 index 0000000000000000000000000000000000000000..51f1e71e28fe3767a70bd39d2712e308b13aa4ca --- /dev/null +++ b/packages/opencode/src/tool/question.ts @@ -0,0 +1,44 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { Question } from "../question" +import DESCRIPTION from "./question.txt" + +export const Parameters = Schema.Struct({ + questions: Schema.mutable(Schema.Array(Question.Prompt)).annotate({ description: "Questions to ask" }), +}) + +type Metadata = { + answers: ReadonlyArray +} + +export const QuestionTool = Tool.define( + "question", + Effect.gen(function* () { + const question = yield* Question.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const answers = yield* question.ask({ + sessionID: ctx.sessionID, + questions: params.questions, + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + + const formatted = params.questions + .map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`) + .join(", ") + + return { + title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`, + output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`, + metadata: { + answers, + }, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/question.txt b/packages/opencode/src/tool/question.txt new file mode 100644 index 0000000000000000000000000000000000000000..03cd496d60a0c236aa0aadfcd20af97fce6360bb --- /dev/null +++ b/packages/opencode/src/tool/question.txt @@ -0,0 +1,10 @@ +Use this tool when you need to ask the user questions during execution. This allows you to: +1. Gather user preferences or requirements +2. Clarify ambiguous instructions +3. Get decisions on implementation choices as you work +4. Offer choices to the user about what direction to take. + +Usage notes: +- When `custom` is enabled (default), a "Type your own answer" option is added automatically; don't include "Other" or catch-all options +- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one +- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts new file mode 100644 index 0000000000000000000000000000000000000000..678ed44510488965bacc87238bf8c80f70ad3c07 --- /dev/null +++ b/packages/opencode/src/tool/read.ts @@ -0,0 +1,386 @@ +import { Effect, Option, Schema, Scope, Stream } from "effect" +import { NonNegativeInt } from "@opencode-ai/core/schema" +import * as path from "path" +import * as Tool from "./tool" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { LSP } from "@/lsp/lsp" +import DESCRIPTION from "./read.txt" +import { InstanceState } from "@/effect/instance-state" +import { assertExternalDirectoryEffect } from "./external-directory" +import { Instruction } from "../session/instruction" +import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" + +const DEFAULT_READ_LIMIT = 2000 +const MAX_LINE_LENGTH = 2000 +const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` +const MAX_BYTES = 50 * 1024 +const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` +const SAMPLE_BYTES = 4096 +const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]) + +class ReadStop extends Schema.TaggedErrorClass()("ReadStop", {}) {} + +// `offset` and `limit` were originally `z.coerce.number()` — the runtime +// coercion was useful when the tool was called from a shell but serves no +// purpose in the LLM tool-call path (the model emits typed JSON). The JSON +// Schema output is identical (`type: "number"`), so the LLM view is +// unchanged; purely CLI-facing uses must now send numbers rather than strings. +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }), + offset: Schema.optional(NonNegativeInt).annotate({ + description: "The line number to start reading from (1-indexed)", + }), + limit: Schema.optional(NonNegativeInt).annotate({ + description: "The maximum number of lines to read (defaults to 2000)", + }), +}) + +type Display = + | { + type: "directory" + path: string + entries: string[] + offset: number + totalEntries: number + truncated: boolean + } + | { + type: "file" + path: string + text: string + lineStart: number + lineEnd: number + totalLines: number + truncated: boolean + } + +type Metadata = { + preview: string + truncated: boolean + loaded: string[] + display?: Display +} + +export const ReadTool = Tool.define< + typeof Parameters, + Metadata, + FSUtil.Service | Instruction.Service | LSP.Service | Scope.Scope +>( + "read", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const instruction = yield* Instruction.Service + const lsp = yield* LSP.Service + const scope = yield* Scope.Scope + + const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) { + const dir = path.dirname(filepath) + const base = path.basename(filepath) + const items = yield* fs.readDirectory(dir).pipe( + Effect.map((items) => + items + .filter( + (item) => + item.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(item.toLowerCase()), + ) + .map((item) => path.join(dir, item)) + .slice(0, 3), + ), + Effect.catch(() => Effect.succeed([] as string[])), + ) + + if (items.length > 0) { + return yield* Effect.fail( + new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${items.join("\n")}`), + ) + } + + return yield* Effect.fail(new Error(`File not found: ${filepath}`)) + }) + + const list = Effect.fn("ReadTool.list")(function* (filepath: string) { + const items = yield* fs.readDirectoryEntries(filepath) + return yield* Effect.forEach( + items, + Effect.fnUntraced(function* (item) { + if (item.type === "directory") return item.name + "/" + if (item.type !== "symlink") return item.name + + const target = yield* fs.stat(path.join(filepath, item.name)).pipe(Effect.catch(() => Effect.void)) + if (target?.type === "Directory") return item.name + "/" + return item.name + }), + { concurrency: "unbounded" }, + ).pipe(Effect.map((items: string[]) => items.sort((a, b) => a.localeCompare(b)))) + }) + + const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) { + // LSP warm-up is optional; do not let a background defect fail an otherwise successful read. + yield* lsp.touchFile(filepath).pipe(Effect.ignoreCause, Effect.forkIn(scope)) + }) + + const readSample = Effect.fn("ReadTool.readSample")(function* ( + filepath: string, + fileSize: number, + sampleSize: number, + ) { + if (fileSize === 0) return new Uint8Array() + + return yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(filepath, { flag: "r" }) + return Option.getOrElse(yield* file.readAlloc(Math.min(sampleSize, fileSize)), () => new Uint8Array()) + }), + ) + }) + + const lines = Effect.fn("ReadTool.lines")(function* (filepath: string, opts: { limit: number; offset: number }) { + const start = opts.offset - 1 + const raw: string[] = [] + const flags = { bytes: 0, count: 0, cut: false, more: false, done: false } + + // Note: prefer manual TextDecoder over Stream.decodeText — when the source stream + // ends without flushing, decodeText drops the final unterminated line. We also + // avoid Stream.runForEachWhile (it currently swallows the final unterminated + // line of the upstream splitLines pipeline) and use a tagged error to stop the + // upstream file stream as soon as the byte cap is reached. + const decoder = new TextDecoder("utf-8") + yield* fs.stream(filepath).pipe( + Stream.map((bytes) => decoder.decode(bytes, { stream: true })), + Stream.splitLines, + Stream.runForEach((text) => + Effect.gen(function* () { + if (flags.done) return yield* new ReadStop() + flags.count += 1 + if (flags.count <= start) return + + if (raw.length >= opts.limit) { + flags.more = true + return + } + + const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text + const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0) + if (flags.bytes + size <= MAX_BYTES) { + raw.push(line) + flags.bytes += size + return + } + + flags.cut = true + flags.more = true + flags.done = true + return yield* new ReadStop() + }), + ), + Effect.catchTag("ReadStop", () => Effect.void), + ) + + return { raw, count: flags.count, cut: flags.cut, more: flags.more, offset: opts.offset } + }) + + const isBinaryFile = (filepath: string, bytes: Uint8Array) => { + const ext = path.extname(filepath).toLowerCase() + switch (ext) { + case ".zip": + case ".tar": + case ".gz": + case ".exe": + case ".dll": + case ".so": + case ".class": + case ".jar": + case ".war": + case ".7z": + case ".doc": + case ".docx": + case ".xls": + case ".xlsx": + case ".ppt": + case ".pptx": + case ".odt": + case ".ods": + case ".odp": + case ".bin": + case ".dat": + case ".obj": + case ".o": + case ".a": + case ".lib": + case ".wasm": + case ".pyc": + case ".pyo": + return true + } + + if (bytes.length === 0) return false + + let nonPrintableCount = 0 + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 0) return true + if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) { + nonPrintableCount++ + } + } + + return nonPrintableCount / bytes.length > 0.3 + } + + const run = Effect.fn("ReadTool.execute")(function* ( + params: Schema.Schema.Type, + ctx: Tool.Context, + ) { + const instance = yield* InstanceState.context + let filepath = params.filePath + if (!path.isAbsolute(filepath)) { + filepath = path.resolve(instance.directory, filepath) + } + if (process.platform === "win32") { + filepath = FSUtil.normalizePath(filepath) + } + const title = path.relative(instance.worktree, filepath) + + const stat = yield* fs.stat(filepath).pipe( + Effect.catchIf( + (err) => "reason" in err && err.reason._tag === "NotFound", + () => Effect.succeed(undefined), + ), + ) + + yield* assertExternalDirectoryEffect(ctx, filepath, { + bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), + kind: stat?.type === "Directory" ? "directory" : "file", + }) + + yield* ctx.ask({ + permission: "read", + patterns: [path.relative(instance.worktree, filepath)], + always: ["*"], + metadata: {}, + }) + + if (!stat) return yield* miss(filepath) + + if (stat.type === "Directory") { + const items = yield* list(filepath) + const limit = params.limit ?? DEFAULT_READ_LIMIT + const offset = params.offset || 1 + const start = offset - 1 + const sliced = items.slice(start, start + limit) + const truncated = start + sliced.length < items.length + + return { + title, + output: [ + `${filepath}`, + `directory`, + ``, + sliced.join("\n"), + truncated + ? `\n(Showing ${sliced.length} of ${items.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})` + : `\n(${items.length} entries)`, + ``, + ].join("\n"), + metadata: { + preview: sliced.slice(0, 20).join("\n"), + truncated, + loaded: [] as string[], + display: { + type: "directory" as const, + path: filepath, + entries: sliced, + offset, + totalEntries: items.length, + truncated, + }, + }, + } + } + + const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID) + const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES) + + const mime = sniffAttachmentMime(sample, FSUtil.mimeType(filepath)) + const isImage = SUPPORTED_IMAGE_MIMES.has(mime) + + if (isImage || isPdfAttachment(mime)) { + const bytes = yield* fs.readFile(filepath) + const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully" + return { + title, + output: msg, + metadata: { + preview: msg, + truncated: false, + loaded: loaded.map((item) => item.filepath), + }, + attachments: [ + { + type: "file" as const, + mime, + url: `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`, + }, + ], + } + } + + if (isBinaryFile(filepath, sample)) { + return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`)) + } + + const file = yield* lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset || 1 }) + if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) { + return yield* Effect.fail( + new Error(`Offset ${file.offset} is out of range for this file (${file.count} lines)`), + ) + } + + let output = [`${filepath}`, `file`, "\n"].join("\n") + output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n") + + const last = file.offset + file.raw.length - 1 + const next = last + 1 + const truncated = file.more || file.cut + if (file.cut) { + output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${file.offset}-${last}. Use offset=${next} to continue.)` + } else if (file.more) { + output += `\n\n(Showing lines ${file.offset}-${last} of ${file.count}. Use offset=${next} to continue.)` + } else { + output += `\n\n(End of file - total ${file.count} lines)` + } + output += "\n" + + yield* warm(filepath) + + if (loaded.length > 0) { + output += `\n\n\n${loaded.map((item) => item.content).join("\n\n")}\n` + } + + return { + title, + output, + metadata: { + preview: file.raw.slice(0, 20).join("\n"), + truncated, + loaded: loaded.map((item) => item.filepath), + display: { + type: "file" as const, + path: filepath, + text: file.raw.join("\n"), + lineStart: file.offset, + lineEnd: last, + totalLines: file.count, + truncated, + }, + }, + } + }) + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + run(params, ctx).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts new file mode 100644 index 0000000000000000000000000000000000000000..9167cb3ea6bc5c8dd075f0f8271adbdec6074b12 --- /dev/null +++ b/packages/opencode/src/tool/registry.ts @@ -0,0 +1,455 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { PlanExitTool } from "./plan" +import { Session } from "@/session/session" +import { QuestionTool } from "./question" +import { ShellTool } from "./shell" +import { EditTool } from "./edit" +import { GlobTool } from "./glob" +import { GrepTool } from "./grep" +import { ReadTool } from "./read" +import { TaskTool } from "./task" +import { Database } from "@opencode-ai/core/database/database" +import { TodoWriteTool } from "./todo" +import { WebFetchTool } from "./webfetch" +import { WriteTool } from "./write" +import { InvalidTool } from "./invalid" +import { SkillTool } from "./skill" +import * as Tool from "./tool" +import { Config } from "@/config/config" +import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" +import type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider" +import { Schema } from "effect" +import z from "zod" +import { Plugin } from "../plugin" +import { Provider } from "@/provider/provider" + +import { WebSearchTool } from "./websearch" +import { LspTool } from "./lsp" +import * as Truncate from "./truncate" +import { ApplyPatchTool } from "./apply_patch" +import { Glob } from "@opencode-ai/core/util/glob" +import path from "path" +import { pathToFileURL } from "url" +import { Effect, Layer, Context } from "effect" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Format } from "../format" +import { InstanceState } from "@/effect/instance-state" +import { EffectBridge } from "@/effect/bridge" +import { Question } from "../question" +import { Todo } from "../session/todo" +import { LSP } from "@/lsp/lsp" +import { Instruction } from "../session/instruction" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Agent } from "../agent/agent" +import { Skill } from "../skill" +import { Permission } from "@/permission" +import { BackgroundJob } from "@/background/job" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { MCP } from "@/mcp" +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { McpCatalog } from "@/mcp/catalog" + +export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) { + return ( + providerID === ProviderV2.ID.opencode || + providerID === ProviderV2.ID.make("opencode-go") || + flags.exa || + flags.parallel + ) +} + +type TaskDef = Tool.InferDef +type ReadDef = Tool.InferDef + +type State = { + custom: Tool.Def[] + builtin: Tool.Def[] + task: TaskDef + read: ReadDef +} + +export interface Interface { + readonly ids: () => Effect.Effect + readonly all: () => Effect.Effect + readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> + readonly tools: (model: { + providerID: ProviderV2.ID + modelID: ModelV2.ID + agent: Agent.Info + permission?: PermissionV1.Ruleset + }) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ToolRegistry") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const plugin = yield* Plugin.Service + const agents = yield* Agent.Service + const truncate = yield* Truncate.Service + const flags = yield* RuntimeFlags.Service + const mcp = yield* MCP.Service + + const invalid = yield* InvalidTool + const task = yield* TaskTool + const read = yield* ReadTool + const question = yield* QuestionTool + const todo = yield* TodoWriteTool + const lsptool = yield* LspTool + const plan = yield* PlanExitTool + const webfetch = yield* WebFetchTool + const websearch = yield* WebSearchTool + const shell = yield* ShellTool + const globtool = yield* GlobTool + const writetool = yield* WriteTool + const edit = yield* EditTool + const greptool = yield* GrepTool + const patchtool = yield* ApplyPatchTool + const skilltool = yield* SkillTool + const agent = yield* Agent.Service + const codeMode = flags.experimentalCodeMode ? yield* Effect.promise(() => import("./code-mode")) : undefined + const codeModeTool = codeMode ? yield* codeMode.CodeModeTool : undefined + + const state = yield* InstanceState.make( + Effect.fn("ToolRegistry.state")(function* (ctx) { + const custom: Tool.Def[] = [] + + function fromPlugin(id: string, def: ToolDefinition): Tool.Def { + // Plugin tools still expose Zod args publicly; keep that compatibility + // boxed at the registry boundary and give the LLM the original JSON Schema. + // Normalize missing args to `{}` once — pre-1.14.49 the code was + // `z.object(def.args)` and Zod silently tolerated undefined (#27451, #27630). + const args = def.args ?? {} + const entries = Object.entries(args) + const allZod = entries.every((entry) => isZodType(entry[1])) + const zodParams = allZod ? z.object(args) : undefined + const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries) + const parameters = zodParams + ? Schema.declare((u): u is unknown => zodParams.safeParse(u).success) + : Schema.Unknown + return { + id, + parameters, + jsonSchema, + description: def.description, + execute: (args, toolCtx) => + Effect.gen(function* () { + // Bridge the host's Effect-based `ask` into a Promise-returning + // function for the plugin to make sure context persists + const bridge = yield* EffectBridge.make() + const pluginCtx: PluginToolContext = { + ...toolCtx, + ask: (req) => bridge.promise(toolCtx.ask(req)), + directory: ctx.directory, + worktree: ctx.worktree, + } + const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) + const output = typeof result === "string" ? result : result.output + const metadata = typeof result === "string" ? {} : (result.metadata ?? {}) + const attachments = typeof result === "string" ? undefined : result.attachments + const info = yield* agent.get(toolCtx.agent) + const out = yield* truncate.output(output, {}, info) + return { + title: typeof result === "string" ? "" : (result.title ?? ""), + output: out.truncated ? out.content : output, + attachments, + metadata: { + ...metadata, + truncated: out.truncated, + ...(out.truncated && { outputPath: out.outputPath }), + }, + } + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": id, + "session.id": toolCtx.sessionID, + "message.id": toolCtx.messageID, + ...(toolCtx.callID ? { "tool.call_id": toolCtx.callID } : {}), + }, + }), + ), + } + } + + const dirs = yield* config.directories() + const matches = dirs.flatMap((dir) => + Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }), + ) + if (matches.length) yield* config.waitForDependencies() + for (const match of matches) { + const namespace = path.basename(match, path.extname(match)) + // `match` is an absolute filesystem path from `Glob.scanSync(..., { absolute: true })`. + // Import it as `file://` so Node on Windows accepts the dynamic import. + const mod = yield* Effect.promise(() => import(pathToFileURL(match).href)) + for (const [id, def] of Object.entries(mod)) { + if (!isPluginTool(def)) continue + custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) + } + } + + const plugins = yield* plugin.list() + for (const p of plugins) { + for (const [id, def] of Object.entries(p.tool ?? {})) { + custom.push(fromPlugin(id, def)) + } + } + + yield* config.get() + const questionEnabled = ["app", "cli", "desktop"].includes(flags.client) || flags.enableQuestionTool + + const tool = yield* Effect.all({ + invalid: Tool.init(invalid), + shell: Tool.init(shell), + read: Tool.init(read), + glob: Tool.init(globtool), + grep: Tool.init(greptool), + edit: Tool.init(edit), + write: Tool.init(writetool), + task: Tool.init(task), + fetch: Tool.init(webfetch), + todo: Tool.init(todo), + search: Tool.init(websearch), + skill: Tool.init(skilltool), + patch: Tool.init(patchtool), + question: Tool.init(question), + lsp: Tool.init(lsptool), + plan: Tool.init(plan), + ...(codeModeTool ? { execute: Tool.init(codeModeTool) } : {}), + }) + + return { + custom, + builtin: [ + tool.invalid, + ...(questionEnabled ? [tool.question] : []), + tool.shell, + tool.read, + tool.glob, + tool.grep, + tool.edit, + tool.write, + tool.task, + tool.fetch, + tool.todo, + tool.search, + tool.skill, + tool.patch, + ...(tool.execute ? [tool.execute] : []), + ...(flags.experimentalLspTool ? [tool.lsp] : []), + ...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []), + ], + task: tool.task, + read: tool.read, + } + }), + ) + + const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () { + const s = yield* InstanceState.get(state) + return [...s.builtin, ...s.custom] as Tool.Def[] + }) + + const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () { + return (yield* all()).map((tool) => tool.id) + }) + + const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) { + const items = (yield* agents.list()).filter((item) => item.mode !== "primary") + const filtered = items.filter( + (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny", + ) + const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name)) + const description = list + .map( + (item) => + `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`, + ) + .join("\n") + return ["Available agent types and the tools they have access to:", description].join("\n") + }) + + const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (input: { + agent: Agent.Info + permission?: PermissionV1.Ruleset + }) { + if (!codeMode) return + const ruleset = Permission.merge(input.agent.permission, input.permission ?? []) + const tools = Permission.visibleTools(yield* mcp.tools(), ruleset) + if (Object.keys(tools).length === 0) return + return codeMode.describeCatalog(tools, Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)) + }) + + const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { + const filtered = (yield* all()).filter((tool) => { + if (tool.id === WebSearchTool.id) { + return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel }) + } + + const usePatch = + input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4") + if (tool.id === ApplyPatchTool.id) return usePatch + if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch + + return true + }) + + const codeModeDescription = filtered.some((tool) => tool.id === "execute") + ? yield* describeCodeMode(input) + : undefined + const visible = filtered.filter((tool) => tool.id !== "execute" || codeModeDescription) + + return yield* Effect.forEach( + visible, + Effect.fnUntraced(function* (tool: Tool.Def) { + const output = { + description: tool.description, + parameters: tool.parameters, + jsonSchema: tool.jsonSchema, + } + yield* plugin.trigger("tool.definition", { toolID: tool.id }, output) + const jsonSchema = + output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema + ? output.jsonSchema + : undefined + return { + id: tool.id, + description: [ + output.description, + tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined, + tool.id === "execute" ? codeModeDescription : undefined, + ] + .filter(Boolean) + .join("\n"), + parameters: output.parameters, + jsonSchema, + execute: tool.execute, + formatValidationError: tool.formatValidationError, + } + }), + { concurrency: "unbounded" }, + ) + }) + + const named: Interface["named"] = Effect.fn("ToolRegistry.named")(function* () { + const s = yield* InstanceState.get(state) + return { task: s.task, read: s.read } + }) + + return Service.of({ ids, all, named, tools }) + }), +) + +function isZodType(value: unknown): value is z.ZodType { + return typeof value === "object" && value !== null && "_zod" in value +} + +function isPluginTool(value: unknown): value is ToolDefinition { + return typeof value === "object" && value !== null && "args" in value && "description" in value && "execute" in value +} + +function isJsonSchemaDefinition(value: unknown): value is JSONSchema7Definition { + return typeof value === "boolean" || (typeof value === "object" && value !== null && !Array.isArray(value)) +} + +function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 { + const properties = Object.fromEntries( + entries.filter((entry): entry is [string, JSONSchema7Definition] => isJsonSchemaDefinition(entry[1])), + ) + return { + type: "object", + properties, + required: Object.keys(properties), + } +} + +function zodJsonSchema(schema: z.ZodType): JSONSchema7 { + const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input", metadata: zodMetadataRegistry(schema) })) + if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema") + const { $defs, ...rest } = result + return ( + $defs && isJsonSchemaObject($defs) ? { ...rest, definitions: $defs as JSONSchema7["definitions"] } : rest + ) as JSONSchema7 +} + +function zodMetadataRegistry(schema: z.ZodType) { + const registry = z.registry>() + const seen = new WeakSet() + const collect = (value: unknown) => { + if (typeof value !== "object" || value === null) return + if (seen.has(value)) return + seen.add(value) + + if (isZodType(value)) { + const metadata = typeof value.meta === "function" ? value.meta() : undefined + const description = typeof value.description === "string" ? value.description : undefined + const merged = { + ...(metadata && typeof metadata === "object" ? metadata : {}), + ...(description ? { description } : {}), + } + if (Object.keys(merged).length) registry.add(value, merged) + collect(value._zod.def) + return + } + + for (const item of Object.values(value)) collect(item) + } + collect(schema) + return registry +} + +function normalizeZodJsonSchema(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item)) + if (typeof value !== "object" || value === null) return value + return Object.fromEntries( + Object.entries(value) + .filter((entry) => + (entry[0] === "exclusiveMaximum" || entry[0] === "exclusiveMinimum") && typeof entry[1] === "boolean" + ? false + : true, + ) + .map(([key, item]) => [key, normalizeZodJsonSchema(item)]), + ) +} + +function isJsonSchemaObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export const node = LayerNode.make({ + service: Service, + layer, + deps: [ + Config.node, + Plugin.node, + Question.node, + Todo.node, + Agent.node, + Skill.node, + Session.node, + BackgroundJob.node, + Provider.node, + LSP.node, + Instruction.node, + FSUtil.node, + EventV2Bridge.node, + httpClient, + CrossSpawnSpawner.node, + Format.node, + Truncate.node, + RuntimeFlags.node, + MCP.node, + Database.node, + Ripgrep.node, + ], +}) + +export * as ToolRegistry from "./registry" diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..c83444866ece50750538f7883e2c1d7e49f8f324 --- /dev/null +++ b/packages/opencode/src/tool/schema.ts @@ -0,0 +1,14 @@ +import { Schema } from "effect" + +import { Identifier } from "@/id/id" +import { statics } from "@opencode-ai/core/schema" + +const toolIdSchema = Schema.String.check(Schema.isStartsWith("tool")).pipe(Schema.brand("ToolID")) + +export type ToolID = typeof toolIdSchema.Type + +export const ToolID = toolIdSchema.pipe( + statics((schema: typeof toolIdSchema) => ({ + ascending: (id?: string) => schema.make(Identifier.ascending("tool", id)), + })), +) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e4423e017740617bc6e0df36ad9dcdb0197bccb --- /dev/null +++ b/packages/opencode/src/tool/shell.ts @@ -0,0 +1,645 @@ +import { Effect, Stream } from "effect" +import os from "os" +import { createWriteStream } from "node:fs" +import * as Tool from "./tool" +import path from "path" +import { containsPath, type InstanceContext } from "../project/instance-context" +import { InstanceState } from "@/effect/instance-state" +import { lazy } from "@/util/lazy" +import { Language, type Node } from "web-tree-sitter" + +import { FSUtil } from "@opencode-ai/core/fs-util" +import { fileURLToPath } from "url" +import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Shell } from "@opencode-ai/core/shell" +import { ShellID } from "./shell/id" + +import * as Truncate from "./truncate" +import { Plugin } from "@/plugin" +import { ChildProcess } from "effect/unstable/process" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { ShellPrompt, type Parameters } from "./shell/prompt" +import { BashArity } from "@/permission/arity" + +export { Parameters } from "./shell/prompt" + +const MAX_METADATA_LENGTH = 30_000 +const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"]) +const FILES = new Set([ + ...CWD, + "rm", + "cp", + "mv", + "mkdir", + "touch", + "chmod", + "chown", + "cat", + // Leave PowerShell aliases out for now. Common ones like cat/cp/mv/rm/mkdir + // already hit the entries above, and alias normalization should happen in one + // place later so we do not risk double-prompting. + "get-content", + "set-content", + "add-content", + "copy-item", + "move-item", + "remove-item", + "new-item", + "rename-item", +]) +const CMD_FILES = new Set([ + "copy", + "del", + "dir", + "erase", + "md", + "mkdir", + "move", + "rd", + "ren", + "rename", + "rmdir", + "type", +]) +const FLAGS = new Set(["-destination", "-literalpath", "-path"]) +const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) + +type Part = { + type: string + text: string +} + +type Scan = { + dirs: Set + patterns: Set + always: Set +} + +type Chunk = { + text: string + size: number +} + +const resolveWasm = (asset: string) => { + if (asset.startsWith("file://")) return fileURLToPath(asset) + if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset + const url = new URL(asset, import.meta.url) + return fileURLToPath(url) +} + +function parts(node: Node) { + const out: Part[] = [] + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i) + if (!child) continue + if (child.type === "command_elements") { + for (let j = 0; j < child.childCount; j++) { + const item = child.child(j) + if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue + out.push({ type: item.type, text: item.text }) + } + continue + } + if ( + child.type !== "command_name" && + child.type !== "command_name_expr" && + child.type !== "word" && + child.type !== "string" && + child.type !== "raw_string" && + child.type !== "concatenation" + ) { + continue + } + out.push({ type: child.type, text: child.text }) + } + return out +} + +function source(node: Node) { + return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim() +} + +function commands(node: Node) { + return node.descendantsOfType("command").filter((child): child is Node => Boolean(child)) +} + +function unquote(text: string) { + if (text.length < 2) return text + const first = text[0] + const last = text[text.length - 1] + if ((first === '"' || first === "'") && first === last) return text.slice(1, -1) + return text +} + +function home(text: string) { + if (text === "~") return os.homedir() + if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2)) + return text +} + +function envValue(key: string) { + if (process.platform !== "win32") return process.env[key] + const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase()) + return name ? process.env[name] : undefined +} + +function auto(key: string, cwd: string, shell: string) { + const name = key.toUpperCase() + if (name === "HOME") return os.homedir() + if (name === "PWD") return cwd + if (name === "PSHOME") return path.dirname(shell) +} + +function expand(text: string, cwd: string, shell: string) { + const out = unquote(text) + .replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => envValue(key) || "") + .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => envValue(key) || "") + .replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => auto(key, cwd, shell) || "") + return home(out) +} + +function provider(text: string) { + const match = text.match(/^([A-Za-z]+)::(.*)$/) + if (match) { + if (match[1].toLowerCase() !== "filesystem") return + return match[2] + } + const prefix = text.match(/^([A-Za-z]+):(.*)$/) + if (!prefix) return text + if (prefix[1].length === 1) return text + return +} + +function dynamic(text: string, ps: boolean) { + if (text.startsWith("(") || text.startsWith("@(")) return true + if (text.includes("$(") || text.includes("${") || text.includes("`")) return true + if (ps) return /\$(?!env:)/i.test(text) + return text.includes("$") +} + +function prefix(text: string) { + const match = /[?*[]/.exec(text) + if (!match) return text + if (match.index === 0) return + return text.slice(0, match.index) +} + +function pathArgs(list: Part[], ps: boolean, cmd = false) { + if (!ps) { + return list + .slice(1) + .filter( + (item) => + !item.text.startsWith("-") && + !(cmd && item.text.startsWith("/")) && + !(list[0]?.text === "chmod" && item.text.startsWith("+")), + ) + .map((item) => item.text) + } + + const out: string[] = [] + let want = false + for (const item of list.slice(1)) { + if (want) { + out.push(item.text) + want = false + continue + } + if (item.type === "command_parameter") { + const flag = item.text.toLowerCase() + if (SWITCHES.has(flag)) continue + want = FLAGS.has(flag) + continue + } + out.push(item.text) + } + return out +} + +function preview(text: string) { + if (text.length <= MAX_METADATA_LENGTH) return text + return "...\n\n" + text.slice(-MAX_METADATA_LENGTH) +} + +function tail(text: string, maxLines: number, maxBytes: number) { + const lines = text.split("\n") + if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes) { + return { + text, + cut: false, + } + } + + const out: string[] = [] + let bytes = 0 + for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + if (out.length === 0) { + const buf = Buffer.from(lines[i], "utf-8") + let start = buf.length - maxBytes + if (start < 0) start = 0 + while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++ + out.unshift(buf.subarray(start).toString("utf-8")) + } + break + } + out.unshift(lines[i]) + bytes += size + } + return { + text: out.join("\n"), + cut: true, + } +} + +const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) { + const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command))) + if (!tree) throw new Error("Failed to parse command") + return tree +}) + +const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, input: { command: string }) { + if (scan.dirs.size > 0) { + const directories = Array.from(scan.dirs) + const globs = directories.map((dir) => { + if (process.platform === "win32") return FSUtil.normalizePathPattern(path.join(dir, "*")) + return path.join(dir, "*") + }) + yield* ctx.ask({ + permission: "external_directory", + patterns: globs, + always: globs, + metadata: { + command: input.command, + directories, + patterns: globs, + }, + }) + } + + if (scan.patterns.size === 0) return + yield* ctx.ask({ + permission: ShellID.ToolID, + patterns: Array.from(scan.patterns), + always: Array.from(scan.always), + metadata: { + command: input.command, + }, + }) +}) + +function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { + if (process.platform === "win32" && Shell.ps(shell)) { + return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { + cwd, + env, + stdin: "ignore", + detached: false, + }) + } + + return ChildProcess.make(command, [], { + shell, + cwd, + env, + stdin: "ignore", + detached: process.platform !== "win32", + }) +} +const parser = lazy(async () => { + const { Parser } = await import("web-tree-sitter") + const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { + with: { type: "wasm" }, + }) + const treePath = resolveWasm(treeWasm) + await Parser.init({ + locateFile() { + return treePath + }, + }) + const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, { + with: { type: "wasm" }, + }) + const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, { + with: { type: "wasm" }, + }) + const bashPath = resolveWasm(bashWasm) + const psPath = resolveWasm(psWasm) + const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)]) + const bash = new Parser() + bash.setLanguage(bashLanguage) + const ps = new Parser() + ps.setLanguage(psLanguage) + return { bash, ps } +}) + +export const ShellTool = Tool.define( + ShellID.ToolID, + Effect.gen(function* () { + const config = yield* Config.Service + const spawner = yield* ChildProcessSpawner + const fs = yield* FSUtil.Service + const trunc = yield* Truncate.Service + const plugin = yield* Plugin.Service + const flags = yield* RuntimeFlags.Service + const defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000 + + const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) { + const lines = yield* spawner + .lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text])) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + const file = lines[0]?.trim() + if (!file) return + return FSUtil.normalizePath(file) + }) + + const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) { + if (process.platform === "win32") { + if (Shell.posix(shell) && text.startsWith("/") && FSUtil.windowsPath(text) === text) { + const file = yield* cygpath(shell, text) + if (file) return file + } + return FSUtil.normalizePath(path.resolve(root, FSUtil.windowsPath(text))) + } + return path.resolve(root, text) + }) + + const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { + const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) + const file = text && prefix(text) + if (!file || dynamic(file, ps)) return + const next = ps ? provider(file) : file + if (!next) return + return yield* resolvePath(next, cwd, shell) + }) + + const collect = Effect.fn("ShellTool.collect")(function* ( + root: Node, + cwd: string, + ps: boolean, + shell: string, + instance: InstanceContext, + ) { + const scan: Scan = { + dirs: new Set(), + patterns: new Set(), + always: new Set(), + } + const shellKind = ShellID.toKind(Shell.name(shell)) + + for (const node of commands(root)) { + const command = parts(node) + const tokens = command.map((item) => item.text) + const cmd = ps || shellKind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0] + + if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) { + for (const arg of pathArgs(command, ps, shellKind === "cmd")) { + const resolved = yield* argPath(arg, cwd, ps, shell) + yield* Effect.logInfo("resolved path", { arg, resolved }) + if (!resolved || containsPath(resolved, instance)) continue + const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved) + scan.dirs.add(dir) + } + } + + if (tokens.length && (!cmd || !CWD.has(cmd))) { + scan.patterns.add(source(node)) + scan.always.add(BashArity.prefix(tokens).join(" ") + " *") + } + } + + return scan + }) + + const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { + const extra = yield* plugin.trigger( + "shell.env", + { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, + { env: {} }, + ) + return { + ...process.env, + ...extra.env, + } + }) + + const run = Effect.fn("ShellTool.run")(function* ( + input: { + shell: string + command: string + cwd: string + env: NodeJS.ProcessEnv + timeout: number + }, + ctx: Tool.Context, + ) { + const limits = yield* trunc.limits() + const keep = limits.maxBytes * 2 + let full = "" + let last = "" + const list: Chunk[] = [] + let used = 0 + let file = "" + let sink: ReturnType | undefined + let cut = false + let expired = false + let aborted = false + + const closeSink = Effect.fnUntraced(function* () { + const stream = sink + if (!stream) return + sink = undefined + if (stream.destroyed || stream.closed) return + yield* Effect.promise( + () => + new Promise((resolve) => { + let settled = false + const done = () => { + if (settled) return + settled = true + stream.off("close", done) + stream.off("error", done) + stream.off("finish", done) + resolve() + } + stream.once("close", done) + stream.once("error", done) + stream.once("finish", done) + stream.end(done) + }), + ).pipe(Effect.catch(() => Effect.void)) + }) + + yield* ctx.metadata({ + metadata: { + output: "", + }, + }) + + const code: number | null = yield* Effect.scoped( + Effect.gen(function* () { + yield* Effect.addFinalizer(closeSink) + const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env)) + + yield* Effect.forkScoped( + Stream.runForEach(Stream.decodeText(handle.all), (chunk) => { + const size = Buffer.byteLength(chunk, "utf-8") + list.push({ text: chunk, size }) + used += size + while (used > keep && list.length > 1) { + const item = list.shift() + if (!item) break + used -= item.size + cut = true + } + + last = preview(last + chunk) + + if (file) { + sink?.write(chunk) + } else { + full += chunk + if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) { + return trunc.write(full).pipe( + Effect.andThen((next) => + Effect.sync(() => { + file = next + cut = true + sink = createWriteStream(next, { flags: "a" }) + full = "" + }), + ), + Effect.andThen( + ctx.metadata({ + metadata: { + output: last, + }, + }), + ), + ) + } + } + + return ctx.metadata({ + metadata: { + output: last, + }, + }) + }), + ) + + const abort = Effect.callback((resume) => { + if (ctx.abort.aborted) return resume(Effect.void) + const handler = () => resume(Effect.void) + ctx.abort.addEventListener("abort", handler, { once: true }) + return Effect.sync(() => ctx.abort.removeEventListener("abort", handler)) + }) + + const timeout = Effect.sleep(`${input.timeout + 100} millis`) + + const exit = yield* Effect.raceAll([ + handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))), + abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))), + timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))), + ]) + + if (exit.kind === "abort") { + aborted = true + yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + } + if (exit.kind === "timeout") { + expired = true + yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + } + + return exit.kind === "exit" ? exit.code : null + }), + ).pipe(Effect.orDie) + + const meta: string[] = [] + if (expired) { + meta.push( + `shell tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, + ) + } + if (aborted) meta.push("User aborted the command") + const raw = list.map((item) => item.text).join("") + const end = tail(raw, limits.maxLines, limits.maxBytes) + if (end.cut) cut = true + if (!file && end.cut) { + file = yield* trunc.write(raw) + } + + let output = end.text + if (!output) output = "(no output)" + + if (cut && file) { + output = `...output truncated...\n\nFull output saved to: ${file}\n\n` + output + } + + if (meta.length > 0) { + output += "\n\n\n" + meta.join("\n") + "\n" + } + return { + title: input.command, + metadata: { + output: last || preview(output), + exit: code, + truncated: cut, + ...(cut && file ? { outputPath: file } : {}), + }, + output, + } + }) + + return () => + Effect.gen(function* () { + const cfg = yield* config.get() + const shell = Shell.acceptable(cfg.shell) + const name = Shell.name(shell) + const limits = yield* trunc.limits() + const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs) + yield* Effect.logInfo("shell tool using shell", { shell }) + + return { + description: prompt.description, + parameters: prompt.parameters, + execute: (params: Parameters, ctx: Tool.Context) => + Effect.gen(function* () { + const instanceCtx = yield* InstanceState.context + const cwd = params.workdir + ? yield* resolvePath(params.workdir, instanceCtx.directory, shell) + : instanceCtx.directory + if (params.timeout !== undefined && params.timeout < 0) { + throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) + } + const timeout = params.timeout ?? defaultTimeoutMs + const ps = Shell.ps(shell) + yield* Effect.scoped( + Effect.gen(function* () { + const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) => + Effect.sync(() => tree.delete()), + ) + const scan = yield* collect(tree.rootNode, cwd, ps, shell, instanceCtx) + if (!containsPath(cwd, instanceCtx)) scan.dirs.add(cwd) + yield* ask(ctx, scan, params) + }), + ) + + return yield* run( + { + shell, + command: params.command, + cwd, + env: yield* shellEnv(ctx, cwd), + timeout, + }, + ctx, + ) + }), + } + }) + }), +) diff --git a/packages/opencode/src/tool/skill.txt b/packages/opencode/src/tool/skill.txt new file mode 100644 index 0000000000000000000000000000000000000000..44d990317ad09c93664949a50f308716ea6dda26 --- /dev/null +++ b/packages/opencode/src/tool/skill.txt @@ -0,0 +1,5 @@ +Load a specialized skill when the task at hand matches one of the skills listed in the system prompt. + +Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill. + +The skill name must match one of the skills listed in your system prompt. diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt new file mode 100644 index 0000000000000000000000000000000000000000..c5e412f409d944fa3e5af5d41ebd7bc784e09b15 --- /dev/null +++ b/packages/opencode/src/tool/task.txt @@ -0,0 +1,19 @@ +Launch a new agent to handle complex, multistep tasks autonomously. + +When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. + +When NOT to use the Task tool: +- If you want to read a specific file path, use the Read or Glob tool instead of the Task tool, to find the match more quickly +- If you are searching for a specific class definition like "class Foo", use the Grep tool instead, to find the match more quickly +- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match more quickly +- If no available agent is a good fit for the task, use other tools directly + + +Usage notes: +1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses +2. Once you have delegated work to an agent, do not duplicate that work yourself. Continue with non-overlapping tasks, or wait for the result. For background tasks, you will be notified automatically when the result is ready. +3. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session. +4. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. +5. The agent's outputs should generally be trusted +6. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). +7. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts new file mode 100644 index 0000000000000000000000000000000000000000..69fb0550e8ca071b7e1ac3b791160746dfdb380f --- /dev/null +++ b/packages/opencode/src/tool/todo.ts @@ -0,0 +1,46 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import DESCRIPTION_WRITE from "./todowrite.txt" +import { Todo } from "../session/todo" + +export const Parameters = Schema.Struct({ + todos: Schema.mutable(Schema.Array(Todo.Info)).annotate({ description: "The updated todo list" }), +}) + +type Metadata = { + todos: Todo.Info[] +} + +export const TodoWriteTool = Tool.define( + "todowrite", + Effect.gen(function* () { + const todo = yield* Todo.Service + + return { + description: DESCRIPTION_WRITE, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "todowrite", + patterns: ["*"], + always: ["*"], + metadata: {}, + }) + + yield* todo.update({ + sessionID: ctx.sessionID, + todos: params.todos, + }) + + return { + title: `${params.todos.filter((x) => x.status !== "completed").length} todos`, + output: JSON.stringify(params.todos, null, 2), + metadata: { + todos: params.todos, + }, + } + }), + } satisfies Tool.DefWithoutID + }), +) diff --git a/packages/opencode/src/tool/todowrite.txt b/packages/opencode/src/tool/todowrite.txt new file mode 100644 index 0000000000000000000000000000000000000000..a21630c15889926db6d8d730f67757603af06db9 --- /dev/null +++ b/packages/opencode/src/tool/todowrite.txt @@ -0,0 +1,44 @@ +Create and maintain a structured task list for the current coding session. Tracks progress, organizes multi-step work, and surfaces status to the user. + +## When to use +Use proactively when: +- The task requires 3+ distinct steps or actions (not just 3 tool calls for a single conceptual step) +- The work is non-trivial and benefits from planning +- The user provides multiple tasks (numbered or comma-separated) or explicitly asks for a todo list +- New instructions arrive - capture them as todos +- You start a task - mark it `in_progress` (only one at a time) before working +- You finish a task - mark it `completed` and add any follow-ups discovered during the work + +## When NOT to use +Skip when: +- The work is a single, straightforward task (or <3 trivial steps) +- The request is purely informational or conversational +- Tracking adds no organizational value + +## States +- `pending` - not started +- `in_progress` - actively working (exactly ONE at a time) +- `completed` - finished successfully +- `cancelled` - no longer needed + +## Rules +- Update status in real time; don't batch completions +- Mark `completed` only after the required work is actually done, including any required verification. Never based on intent. +- Keep exactly one `in_progress` while work remains +- If blocked or partial, keep it `in_progress` and add a follow-up todo describing the blocker +- Preserve user-provided commands verbatim (flags, args, order) +- Items should be specific and actionable; break large work into smaller steps + +## Examples + +Use it: +- "Add a dark mode toggle and run the tests" -> multi-step feature + explicit verification +- "Rename getCwd -> getCurrentWorkingDirectory across the repo" -> grep reveals 15 occurrences in 8 files +- "Implement registration, catalog, cart, checkout" -> multiple complex features + +Skip it: +- "How do I print Hello World in Python?" -> informational +- "Add a comment to calculateTotal" -> single edit +- "Run npm install and tell me what happened" -> one command + +When in doubt, use it. diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5e7802858ca5cd2250f8f34c4725a25c7a3221d --- /dev/null +++ b/packages/opencode/src/tool/tool.ts @@ -0,0 +1,183 @@ +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Effect, Schema } from "effect" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import type { JSONSchema7 } from "@ai-sdk/provider" +import type { MessageV2 } from "../session/message-v2" +import type { Permission } from "../permission" +import type { SessionID, MessageID } from "../session/schema" +import * as Truncate from "./truncate" +import { Agent } from "@/agent/agent" + +interface Metadata { + [key: string]: any +} + +// TODO: remove this hack +export type DynamicDescription = (agent: Agent.Info) => Effect.Effect + +/** + * Raised when the LLM calls a tool with arguments that fail the parameter + * schema. This is the canonical "rewrite the input" tool error: the typed + * error class makes it matchable upstream, and its `message` getter produces + * the model-facing prose that the AI SDK feeds back as the tool result. + */ +export class InvalidArgumentsError extends Schema.TaggedErrorClass()( + "ToolInvalidArgumentsError", + { + tool: Schema.String, + detail: Schema.String, + }, +) { + override get message() { + return `The ${this.tool} tool was called with invalid arguments: ${this.detail}.\nPlease rewrite the input so it satisfies the expected schema.` + } +} + +export type Context = { + sessionID: SessionID + messageID: MessageID + agent: string + abort: AbortSignal + callID?: string + extra?: { [key: string]: unknown } + messages: SessionV1.WithParts[] + metadata(input: { title?: string; metadata?: M }): Effect.Effect + ask(input: Omit): Effect.Effect +} + +export interface ExecuteResult { + title: string + metadata: M + output: string + attachments?: Omit[] +} + +export interface Def< + Parameters extends Schema.Decoder = Schema.Decoder, + M extends Metadata = Metadata, +> { + id: string + description: string + parameters: Parameters + jsonSchema?: JSONSchema7 + execute(args: Schema.Schema.Type, ctx: Context): Effect.Effect> + formatValidationError?(error: unknown): string +} +export type DefWithoutID< + Parameters extends Schema.Decoder = Schema.Decoder, + M extends Metadata = Metadata, +> = Omit, "id"> + +export interface Info< + Parameters extends Schema.Decoder = Schema.Decoder, + M extends Metadata = Metadata, +> { + id: string + init: () => Effect.Effect> +} + +type Init, M extends Metadata> = + | DefWithoutID + | (() => Effect.Effect>) + +export type InferParameters = + T extends Info + ? Schema.Schema.Type

+ : T extends Effect.Effect, any, any> + ? Schema.Schema.Type

+ : never +export type InferMetadata = + T extends Info ? M : T extends Effect.Effect, any, any> ? M : never + +export type InferDef = + T extends Info + ? Def + : T extends Effect.Effect, any, any> + ? Def + : never + +function wrap, Result extends Metadata>( + id: string, + init: Init, + truncate: Truncate.Interface, + agents: Agent.Interface, +) { + return () => + Effect.gen(function* () { + const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init } + // Compile the parser closure once per tool init; `decodeUnknownEffect` + // allocates a new closure per call, so hoisting avoids re-closing it for + // every LLM tool invocation. + const decode = Schema.decodeUnknownEffect(toolInfo.parameters) + const execute = toolInfo.execute + toolInfo.execute = (args, ctx) => { + const attrs = { + "tool.name": id, + "session.id": ctx.sessionID, + "message.id": ctx.messageID, + ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}), + } + return Effect.gen(function* () { + const decoded = yield* decode(args).pipe( + Effect.mapError( + (error) => + new InvalidArgumentsError({ + tool: id, + detail: toolInfo.formatValidationError ? toolInfo.formatValidationError(error) : String(error), + }), + ), + ) + const result = yield* execute(decoded as Schema.Schema.Type, ctx) + if (result.metadata.truncated !== undefined) { + return result + } + const agent = yield* agents.get(ctx.agent) + const truncated = yield* truncate.output(result.output, {}, agent) + return { + ...result, + output: truncated.content, + metadata: { + ...result.metadata, + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + } + }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs })) + } + return toolInfo + }) +} + +export function define< + Parameters extends Schema.Decoder, + Result extends Metadata, + R, + ID extends string = string, +>( + id: ID, + init: Effect.Effect, never, R>, +): Effect.Effect, never, R | Truncate.Service | Agent.Service> & { id: ID } { + return Object.assign( + Effect.gen(function* () { + const resolved = yield* init + const truncate = yield* Truncate.Service + const agents = yield* Agent.Service + return { id, init: wrap(id, resolved, truncate, agents) } + }), + { id }, + ) +} + +export function init

, M extends Metadata>( + info: Info, +): Effect.Effect> { + return Effect.gen(function* () { + const init = yield* info.init() + return { + ...init, + id: info.id, + } + }) +} + +export * as Tool from "./tool" diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6150345459efddbd0d4955f3b210f7998e07c6d --- /dev/null +++ b/packages/opencode/src/tool/webfetch.ts @@ -0,0 +1,192 @@ +import { Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { Parser } from "htmlparser2" +import * as Tool from "./tool" +import TurndownService from "turndown" +import DESCRIPTION from "./webfetch.txt" +import { isImageAttachment } from "@/util/media" + +const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB +const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds +const MAX_TIMEOUT = 120 * 1000 // 2 minutes + +export const Parameters = Schema.Struct({ + url: Schema.String.annotate({ description: "The URL to fetch content from" }), + format: Schema.Literals(["text", "markdown", "html"]) + .annotate({ + description: "The format to return the content in (text, markdown, or html). Defaults to markdown.", + default: "markdown", + }) + .pipe(Schema.withDecodingDefault(Effect.succeed("markdown" as const))), + timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }), +}) + +export const WebFetchTool = Tool.define( + "webfetch", + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const httpOk = HttpClient.filterStatusOk(http) + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) { + throw new Error("URL must start with http:// or https://") + } + + yield* ctx.ask({ + permission: "webfetch", + patterns: [params.url], + always: ["*"], + metadata: { + url: params.url, + format: params.format, + timeout: params.timeout, + }, + }) + + const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) + + // Build Accept header based on requested format with q parameters for fallbacks + let acceptHeader = "*/*" + switch (params.format) { + case "markdown": + acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1" + break + case "text": + acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1" + break + case "html": + acceptHeader = + "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" + break + default: + acceptHeader = + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" + } + const headers = { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + Accept: acceptHeader, + "Accept-Language": "en-US,en;q=0.9", + } + + const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers)) + + // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) + const response = yield* httpOk.execute(request).pipe( + Effect.catchIf( + (err) => + err.reason._tag === "StatusCodeError" && + err.reason.response.status === 403 && + err.reason.response.headers["cf-mitigated"] === "challenge", + () => + httpOk.execute( + HttpClientRequest.get(params.url).pipe( + HttpClientRequest.setHeaders({ ...headers, "User-Agent": "opencode" }), + ), + ), + ), + Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }), + ) + + // Check content length + const contentLength = response.headers["content-length"] + if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) { + throw new Error("Response too large (exceeds 5MB limit)") + } + + const arrayBuffer = yield* response.arrayBuffer + if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) { + throw new Error("Response too large (exceeds 5MB limit)") + } + + const contentType = response.headers["content-type"] || "" + const mime = contentType.split(";")[0]?.trim().toLowerCase() || "" + const title = `${params.url} (${contentType})` + + if (isImageAttachment(mime)) { + const base64Content = Buffer.from(arrayBuffer).toString("base64") + return { + title, + output: "Image fetched successfully", + metadata: {}, + attachments: [ + { + type: "file" as const, + mime, + url: `data:${mime};base64,${base64Content}`, + }, + ], + } + } + + const content = new TextDecoder().decode(arrayBuffer) + + // Handle content based on requested format and actual content type + switch (params.format) { + case "markdown": + if (contentType.includes("text/html")) { + const markdown = convertHTMLToMarkdown(content) + return { + output: markdown, + title, + metadata: {}, + } + } + return { output: content, title, metadata: {} } + + case "text": + if (contentType.includes("text/html")) { + return { output: extractTextFromHTML(content), title, metadata: {} } + } + return { output: content, title, metadata: {} } + + case "html": + return { output: content, title, metadata: {} } + + default: + return { output: content, title, metadata: {} } + } + }).pipe(Effect.orDie), + } + }), +) + +function extractTextFromHTML(html: string) { + let text = "" + let skipDepth = 0 + + const parser = new Parser({ + onopentag(name) { + if (skipDepth > 0 || ["script", "style", "noscript", "iframe", "object", "embed"].includes(name)) { + skipDepth++ + } + }, + ontext(input) { + if (skipDepth === 0) text += input + }, + onclosetag() { + if (skipDepth > 0) skipDepth-- + }, + }) + + parser.write(html) + parser.end() + + return text.trim() +} + +function convertHTMLToMarkdown(html: string): string { + const turndownService = new TurndownService({ + headingStyle: "atx", + hr: "---", + bulletListMarker: "-", + codeBlockStyle: "fenced", + emDelimiter: "*", + }) + turndownService.remove(["script", "style", "meta", "link"]) + return turndownService.turndown(html) +} diff --git a/packages/opencode/src/tool/webfetch.txt b/packages/opencode/src/tool/webfetch.txt new file mode 100644 index 0000000000000000000000000000000000000000..169aadefa36837eea76ea88b6042ae1d0886e94f --- /dev/null +++ b/packages/opencode/src/tool/webfetch.txt @@ -0,0 +1,13 @@ +- Fetches content from a specified URL +- Takes a URL and optional format as input +- Fetches the URL content, converts to requested format (markdown by default) +- Returns the content in the specified format +- Use this tool when you need to retrieve and analyze web content + +Usage notes: + - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one. + - The URL must be a fully-formed valid URL + - HTTP URLs will be automatically upgraded to HTTPS + - Format options: "markdown" (default), "text", or "html" + - This tool is read-only and does not modify any files + - Results may be summarized if the content is very large diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts new file mode 100644 index 0000000000000000000000000000000000000000..d08ae1d153e8dcab37d881e238be2c06382cf501 --- /dev/null +++ b/packages/opencode/src/tool/websearch.ts @@ -0,0 +1,143 @@ +import { Effect, Schema } from "effect" +import { HttpClient } from "effect/unstable/http" +import * as Tool from "./tool" +import * as McpWebSearch from "./mcp-websearch" +import DESCRIPTION from "./websearch.txt" +import { checksum } from "@opencode-ai/core/util/encode" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { RuntimeFlags } from "@/effect/runtime-flags" + +export const Parameters = Schema.Struct({ + query: Schema.String.annotate({ description: "Websearch query" }), + numResults: Schema.optional(Schema.Number).annotate({ + description: "Number of search results to return (default: 8)", + }), + livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({ + description: + "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", + }), + type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({ + description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", + }), + contextMaxCharacters: Schema.optional(Schema.Number).annotate({ + description: "Maximum characters for context string optimized for LLMs (default: 10000)", + }), +}) + +const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"]) +export type WebSearchProvider = Schema.Schema.Type + +export function selectWebSearchProvider(sessionID: string, flags = { exa: false, parallel: false }): WebSearchProvider { + const override = process.env.OPENCODE_WEBSEARCH_PROVIDER + if (override === "exa" || override === "parallel") return override + if (flags.parallel) return "parallel" + if (flags.exa) return "exa" + + return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel" +} + +export function webSearchProviderLabel(provider: unknown) { + if (provider === "parallel") return "Parallel Web Search" + if (provider === "exa") return "Exa Web Search" + return "Web Search" +} + +export function webSearchModelName(extra: Tool.Context["extra"]) { + const model = extra?.model + if (!model || typeof model !== "object") return undefined + const api = "api" in model && model.api && typeof model.api === "object" ? model.api : undefined + const apiID = api && "id" in api && typeof api.id === "string" ? api.id : undefined + const id = "id" in model && typeof model.id === "string" ? model.id : undefined + return (apiID ?? id)?.slice(0, 100) +} + +function parallelAuthHeaders() { + const headers = { "User-Agent": `opencode/${InstallationVersion}` } + if (!process.env.PARALLEL_API_KEY) return headers + return { ...headers, Authorization: `Bearer ${process.env.PARALLEL_API_KEY}` } +} + +function callProvider( + http: HttpClient.HttpClient, + provider: WebSearchProvider, + params: Schema.Schema.Type, + ctx: Tool.Context, +) { + if (provider === "parallel") { + return McpWebSearch.call( + http, + McpWebSearch.PARALLEL_URL, + "web_search", + McpWebSearch.ParallelSearchArgs, + { + objective: params.query, + search_queries: [params.query], + session_id: ctx.sessionID, + model_name: webSearchModelName(ctx.extra), + }, + "25 seconds", + parallelAuthHeaders(), + ) + } + + return McpWebSearch.call( + http, + McpWebSearch.EXA_URL, + "web_search_exa", + McpWebSearch.SearchArgs, + { + query: params.query, + type: params.type || "auto", + numResults: params.numResults || 8, + livecrawl: params.livecrawl || "fallback", + contextMaxCharacters: params.contextMaxCharacters, + }, + "25 seconds", + ) +} + +export const WebSearchTool = Tool.define( + "websearch", + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const flags = yield* RuntimeFlags.Service + + return { + get description() { + return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString()) + }, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const provider = selectWebSearchProvider(ctx.sessionID, { + exa: flags.enableExa, + parallel: flags.enableParallel, + }) + const title = webSearchProviderLabel(provider) + yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } }) + + yield* ctx.ask({ + permission: "websearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + numResults: params.numResults, + livecrawl: params.livecrawl, + type: params.type, + contextMaxCharacters: params.contextMaxCharacters, + provider, + }, + }) + + const result = yield* callProvider(http, provider, params, ctx) + + return { + output: result ?? "No search results found. Please try a different query.", + title: `${title}: ${params.query}`, + metadata: { provider }, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/websearch.txt b/packages/opencode/src/tool/websearch.txt new file mode 100644 index 0000000000000000000000000000000000000000..ad5238cbd5cda46a824dc9d0f3637363e58ae6ed --- /dev/null +++ b/packages/opencode/src/tool/websearch.txt @@ -0,0 +1,14 @@ +- Search the web using the session's web search provider - performs real-time web searches and can scrape content from specific URLs +- Provides up-to-date information for current events and recent data +- Supports configurable result counts and returns the content from the most relevant websites +- Use this tool for accessing information beyond knowledge cutoff +- Searches are performed automatically within a single API call + +Usage notes: + - Supports live crawling modes when available: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) + - Search types when available: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) + - Configurable context length for optimal LLM integration + - Domain filtering and advanced search options available + +The current year is {{year}}. You MUST use this year when searching for recent information or current events +- Example: If the current year is 2026 and the user asks for "latest AI news", search for "AI news 2026", NOT "AI news 2025" diff --git a/packages/opencode/src/tool/write.txt b/packages/opencode/src/tool/write.txt new file mode 100644 index 0000000000000000000000000000000000000000..063cbb1f0387e8e57db691b617bf866b82d1e52a --- /dev/null +++ b/packages/opencode/src/tool/write.txt @@ -0,0 +1,8 @@ +Writes a file to the local filesystem. + +Usage: +- This tool will overwrite the existing file if there is one at the provided path. +- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. diff --git a/packages/opencode/src/util/archive.ts b/packages/opencode/src/util/archive.ts new file mode 100644 index 0000000000000000000000000000000000000000..8fc549779393de6f0b1e86bcec2107f20681f1a9 --- /dev/null +++ b/packages/opencode/src/util/archive.ts @@ -0,0 +1,17 @@ +import path from "path" +import * as Process from "./process" + +export async function extractZip(zipPath: string, destDir: string) { + if (process.platform === "win32") { + const winZipPath = path.resolve(zipPath) + const winDestDir = path.resolve(destDir) + // $global:ProgressPreference suppresses PowerShell's blue progress bar popup + const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force` + await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd]) + return + } + + await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir]) +} + +export * as Archive from "./archive" diff --git a/packages/opencode/src/util/bom.ts b/packages/opencode/src/util/bom.ts new file mode 100644 index 0000000000000000000000000000000000000000..f015651e97fd936ac9a4bc4e37af42da3e73ebd8 --- /dev/null +++ b/packages/opencode/src/util/bom.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" + +const BOM_CODE = 0xfeff +const BOM = String.fromCharCode(BOM_CODE) + +export function split(text: string) { + if (text.charCodeAt(0) !== BOM_CODE) return { bom: false, text } + return { bom: true, text: text.slice(1) } +} + +export function join(text: string, bom: boolean) { + const stripped = split(text).text + if (!bom) return stripped + return BOM + stripped +} + +export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filePath: string) { + return split(new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(filePath))) +}) + +export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filePath: string, bom: boolean) { + const current = yield* readFile(fs, filePath) + if (current.bom === bom) return current.text + yield* fs.writeWithDirs(filePath, join(current.text, bom)) + return current.text +}) diff --git a/packages/opencode/src/util/data-url.ts b/packages/opencode/src/util/data-url.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fafcbc63f2b8002afdbc2297026e7af6fee2b13 --- /dev/null +++ b/packages/opencode/src/util/data-url.ts @@ -0,0 +1,9 @@ +export function decodeDataUrl(url: string) { + const idx = url.indexOf(",") + if (idx === -1) return "" + + const head = url.slice(0, idx) + const body = url.slice(idx + 1) + if (head.includes(";base64")) return Buffer.from(body, "base64").toString("utf8") + return decodeURIComponent(body) +} diff --git a/packages/opencode/src/util/defer.ts b/packages/opencode/src/util/defer.ts new file mode 100644 index 0000000000000000000000000000000000000000..33eb4d74d2387937b8a048b6154ff22d77a1f052 --- /dev/null +++ b/packages/opencode/src/util/defer.ts @@ -0,0 +1,10 @@ +export function defer(fn: () => void | Promise): AsyncDisposable & Disposable { + return { + [Symbol.dispose]() { + void fn() + }, + [Symbol.asyncDispose]() { + return Promise.resolve(fn()) + }, + } +} diff --git a/packages/opencode/src/util/effect-http-client.ts b/packages/opencode/src/util/effect-http-client.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c95e34b52c44b09ac04f2b38fc52679695789c3 --- /dev/null +++ b/packages/opencode/src/util/effect-http-client.ts @@ -0,0 +1,11 @@ +import { Schedule } from "effect" +import { HttpClient } from "effect/unstable/http" + +export const withTransientReadRetry = (client: HttpClient.HttpClient.With) => + client.pipe( + HttpClient.retryTransient({ + retryOn: "errors-and-responses", + times: 2, + schedule: Schedule.exponential(200).pipe(Schedule.jittered), + }), + ) diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts new file mode 100644 index 0000000000000000000000000000000000000000..02c5657ea26efa8bf6e4472a4b290bb2f715e38e --- /dev/null +++ b/packages/opencode/src/util/error.ts @@ -0,0 +1 @@ +export * from "@opencode-ai/tui/util/error" diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts new file mode 100644 index 0000000000000000000000000000000000000000..78a89174145dd1257a2d558e0538c3dd10b07508 --- /dev/null +++ b/packages/opencode/src/util/filesystem.ts @@ -0,0 +1,251 @@ +import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises" +import { createWriteStream, existsSync, statSync } from "fs" +import { realpathSync } from "fs" +import { dirname, isAbsolute, join, resolve as pathResolve, win32 } from "path" +import { Readable } from "stream" +import { pipeline } from "stream/promises" +import { Glob } from "@opencode-ai/core/util/glob" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { fileURLToPath } from "url" + +// Fast sync version for metadata checks +export async function exists(p: string): Promise { + return existsSync(p) +} + +export async function isDir(p: string): Promise { + try { + return statSync(p).isDirectory() + } catch { + return false + } +} + +export function stat(p: string): ReturnType | undefined { + return statSync(p, { throwIfNoEntry: false }) ?? undefined +} + +export async function statAsync(p: string): Promise | undefined> { + return statFile(p).catch((e) => { + if (isEnoent(e)) return undefined + throw e + }) +} + +export async function size(p: string): Promise { + const s = stat(p)?.size ?? 0 + return typeof s === "bigint" ? Number(s) : s +} + +export async function readText(p: string): Promise { + return readFile(p, "utf-8") +} + +export async function readJson(p: string): Promise { + return JSON.parse(await readFile(p, "utf-8")) +} + +export async function readBytes(p: string): Promise { + return readFile(p) +} + +export async function readArrayBuffer(p: string): Promise { + const buf = await readFile(p) + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer +} + +function isEnoent(e: unknown): e is { code: "ENOENT" } { + return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT" +} + +export async function write(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise { + try { + if (mode) { + await writeFile(p, content, { mode }) + } else { + await writeFile(p, content) + } + } catch (e) { + if (isEnoent(e)) { + await mkdir(dirname(p), { recursive: true }) + if (mode) { + await writeFile(p, content, { mode }) + } else { + await writeFile(p, content) + } + return + } + throw e + } +} + +export async function writeJson(p: string, data: unknown, mode?: number): Promise { + return write(p, JSON.stringify(data, null, 2), mode) +} + +export async function writeStream( + p: string, + stream: ReadableStream | Readable, + mode?: number, +): Promise { + const dir = dirname(p) + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }) + } + + const nodeStream = stream instanceof ReadableStream ? Readable.fromWeb(stream as any) : stream + const writeStream = createWriteStream(p) + await pipeline(nodeStream, writeStream) + + if (mode) { + await chmod(p, mode) + } +} + +export async function mimeType(p: string): Promise { + const { lookup } = await import("mime-types") + return lookup(p) || "application/octet-stream" +} + +/** + * On Windows, normalize a path to its canonical casing using the filesystem. + * This is needed because Windows paths are case-insensitive but LSP servers + * may return paths with different casing than what we send them. + */ +export function normalizePath(p: string): string { + if (process.platform !== "win32") return p + const resolved = win32.normalize(win32.resolve(windowsPath(p))) + try { + return realpathSync.native(resolved) + } catch { + return resolved + } +} + +export function normalizePathPattern(p: string): string { + if (process.platform !== "win32") return p + if (p === "*") return p + const match = p.match(/^(.*)[\\/]\*$/) + if (!match) return normalizePath(p) + const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1] + return join(normalizePath(dir), "*") +} + +// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary. +// Also resolves symlinks so that callers using the result as a cache key +// always get the same canonical path for a given physical directory. +export function resolve(p: string): string { + const resolved = pathResolve(windowsPath(p)) + try { + return normalizePath(realpathSync(resolved)) + } catch (e) { + if (isEnoent(e)) return normalizePath(resolved) + throw e + } +} + +export function resolveFilePath(root: string, file: string): string { + const raw = file.startsWith("file://") ? fileURLToPath(file) : file + if (isAbsolute(raw)) return raw + return pathResolve(root, raw) +} + +export function windowsPath(p: string): string { + if (process.platform !== "win32") return p + return ( + p + .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + // Git Bash for Windows paths are typically //... + .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + // Cygwin git paths are typically /cygdrive//... + .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + // WSL paths are typically /mnt//... + .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + ) +} +export function overlaps(a: string, b: string) { + return FSUtil.overlaps(a, b) +} + +export function contains(parent: string, child: string) { + return FSUtil.contains(parent, child) +} + +export async function findUp( + target: string, + start: string, + stop?: string, + options?: { rootFirst?: boolean }, +): Promise +export async function findUp( + target: string[], + start: string, + stop?: string, + options?: { rootFirst?: boolean }, +): Promise +export async function findUp( + target: string | string[], + start: string, + stop?: string, + options?: { rootFirst?: boolean }, +) { + const dirs = [start] + let current = start + while (true) { + if (stop === current) break + const parent = dirname(current) + if (parent === current) break + dirs.push(parent) + current = parent + } + + const targets = Array.isArray(target) ? target : [target] + const result = [] + for (const dir of options?.rootFirst ? dirs.toReversed() : dirs) { + for (const item of targets) { + const search = join(dir, item) + if (await exists(search)) result.push(search) + } + } + return result +} + +export async function* up(options: { targets: string[]; start: string; stop?: string }) { + const { targets, start, stop } = options + let current = start + while (true) { + for (const target of targets) { + const search = join(current, target) + if (await exists(search)) yield search + } + if (stop === current) break + const parent = dirname(current) + if (parent === current) break + current = parent + } +} + +export async function globUp(pattern: string, start: string, stop?: string) { + let current = start + const result = [] + while (true) { + try { + const matches = await Glob.scan(pattern, { + cwd: current, + absolute: true, + include: "file", + dot: true, + }) + result.push(...matches) + } catch { + // Skip invalid glob patterns + } + if (stop === current) break + const parent = dirname(current) + if (parent === current) break + current = parent + } + return result +} + +export * as Filesystem from "./filesystem" diff --git a/packages/opencode/src/util/html.ts b/packages/opencode/src/util/html.ts new file mode 100644 index 0000000000000000000000000000000000000000..55028613f7b72b578b04b5902f4ea3a5a9d8fc86 --- /dev/null +++ b/packages/opencode/src/util/html.ts @@ -0,0 +1,8 @@ +export function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} diff --git a/packages/opencode/src/util/iife.ts b/packages/opencode/src/util/iife.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca9ae6c10b44cfcfa5241048196675039ef8f8ec --- /dev/null +++ b/packages/opencode/src/util/iife.ts @@ -0,0 +1,3 @@ +export function iife(fn: () => T) { + return fn() +} diff --git a/packages/opencode/src/util/lazy.ts b/packages/opencode/src/util/lazy.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9abf18a526c2ffccc2e5808f453ef525cdd7fad --- /dev/null +++ b/packages/opencode/src/util/lazy.ts @@ -0,0 +1,20 @@ +export function lazy(fn: () => T) { + let value: T | undefined + let loaded = false + + const result = (): T => { + if (loaded) return value as T + value = fn() + loaded = true + return value as T + } + + result.reset = () => { + loaded = false + value = undefined + } + + result.loaded = () => loaded + + return result +} diff --git a/packages/opencode/src/util/local-context.ts b/packages/opencode/src/util/local-context.ts new file mode 100644 index 0000000000000000000000000000000000000000..deb866f400ec16e8f82977b00be24cf16ec90026 --- /dev/null +++ b/packages/opencode/src/util/local-context.ts @@ -0,0 +1,25 @@ +import { AsyncLocalStorage } from "async_hooks" + +export class NotFound extends Error { + constructor(public override readonly name: string) { + super(`No context found for ${name}`) + } +} + +export function create(name: string) { + const storage = new AsyncLocalStorage() + return { + use() { + const result = storage.getStore() + if (!result) { + throw new NotFound(name) + } + return result + }, + provide(value: T, fn: () => R) { + return storage.run(value, fn) + }, + } +} + +export * as LocalContext from "./local-context" diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts new file mode 100644 index 0000000000000000000000000000000000000000..a082133809391928214963160eb0b253b43f807f --- /dev/null +++ b/packages/opencode/src/util/locale.ts @@ -0,0 +1,2 @@ +export * from "@opencode-ai/tui/util/locale" +export { Locale } from "@opencode-ai/tui/util/locale" diff --git a/packages/opencode/src/util/media.ts b/packages/opencode/src/util/media.ts new file mode 100644 index 0000000000000000000000000000000000000000..566ac843a6342925e3e3d53f9b7dedf90c2d4a8e --- /dev/null +++ b/packages/opencode/src/util/media.ts @@ -0,0 +1,26 @@ +const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value) + +export function isPdfAttachment(mime: string) { + return mime === "application/pdf" +} + +export function isMedia(mime: string) { + return mime.startsWith("image/") || isPdfAttachment(mime) +} + +export function isImageAttachment(mime: string) { + return mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" +} + +export function sniffAttachmentMime(bytes: Uint8Array, fallback: string) { + if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png" + if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg" + if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif" + if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp" + if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf" + if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])) { + return "image/webp" + } + + return fallback +} diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts new file mode 100644 index 0000000000000000000000000000000000000000..173210f23c95d3f4ecb0302ea4128785a9e0a505 --- /dev/null +++ b/packages/opencode/src/util/process.ts @@ -0,0 +1,177 @@ +import { type ChildProcess } from "child_process" +import type { Stream } from "node:stream" +import launch from "cross-spawn" +import { buffer } from "node:stream/consumers" +import { errorMessage } from "./error" + +export type Stdio = "inherit" | "pipe" | "ignore" | number | Stream +export type Shell = boolean | string + +export interface Options { + cwd?: string + env?: NodeJS.ProcessEnv | null + stdin?: Stdio + stdout?: Stdio + stderr?: Stdio + shell?: Shell + abort?: AbortSignal + kill?: NodeJS.Signals | number + timeout?: number +} + +export interface RunOptions extends Omit { + nothrow?: boolean +} + +export interface Result { + code: number + stdout: Buffer + stderr: Buffer +} + +export interface TextResult extends Result { + text: string +} + +export class RunFailedError extends Error { + readonly cmd: string[] + readonly code: number + readonly stdout: Buffer + readonly stderr: Buffer + + constructor(cmd: string[], code: number, stdout: Buffer, stderr: Buffer) { + const text = stderr.toString().trim() + super( + text + ? `Command failed with code ${code}: ${cmd.join(" ")}\n${text}` + : `Command failed with code ${code}: ${cmd.join(" ")}`, + ) + this.name = "ProcessRunFailedError" + this.cmd = [...cmd] + this.code = code + this.stdout = stdout + this.stderr = stderr + } +} + +export type Child = ChildProcess & { exited: Promise } + +export function spawn(cmd: string[], opts: Options = {}): Child { + if (cmd.length === 0) throw new Error("Command is required") + opts.abort?.throwIfAborted() + + const proc = launch(cmd[0], cmd.slice(1), { + cwd: opts.cwd, + shell: opts.shell, + env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined, + stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"], + windowsHide: process.platform === "win32", + }) + + let closed = false + let timer: ReturnType | undefined + + const abort = () => { + if (closed) return + if (proc.exitCode !== null || proc.signalCode !== null) return + closed = true + + proc.kill(opts.kill ?? "SIGTERM") + + const ms = opts.timeout ?? 5_000 + if (ms <= 0) return + timer = setTimeout(() => proc.kill("SIGKILL"), ms) + } + + const exited = new Promise((resolve, reject) => { + const done = () => { + opts.abort?.removeEventListener("abort", abort) + if (timer) clearTimeout(timer) + } + + proc.once("exit", (code, signal) => { + done() + resolve(code ?? (signal ? 1 : 0)) + }) + + proc.once("error", (error) => { + done() + reject(error) + }) + }) + void exited.catch(() => undefined) + + if (opts.abort) { + opts.abort.addEventListener("abort", abort, { once: true }) + if (opts.abort.aborted) abort() + } + + const child = proc as Child + child.exited = exited + return child +} + +export async function run(cmd: string[], opts: RunOptions = {}): Promise { + const proc = spawn(cmd, { + cwd: opts.cwd, + env: opts.env, + stdin: opts.stdin, + shell: opts.shell, + abort: opts.abort, + kill: opts.kill, + timeout: opts.timeout, + stdout: "pipe", + stderr: "pipe", + }) + + if (!proc.stdout || !proc.stderr) throw new Error("Process output not available") + + const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)]) + .then(([code, stdout, stderr]) => ({ + code, + stdout, + stderr, + })) + .catch((err: unknown) => { + if (!opts.nothrow) throw err + return { + code: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.from(errorMessage(err)), + } + }) + if (out.code === 0 || opts.nothrow) return out + throw new RunFailedError(cmd, out.code, out.stdout, out.stderr) +} + +// Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import +// `opencode` without creating a cycle. Keep both copies in sync. +export async function stop(proc: ChildProcess) { + if (proc.exitCode !== null || proc.signalCode !== null) return + + if (process.platform !== "win32" || !proc.pid) { + proc.kill() + return + } + + const out = await run(["taskkill", "/pid", String(proc.pid), "/T", "/F"], { + nothrow: true, + }) + + if (out.code === 0) return + proc.kill() +} + +export async function text(cmd: string[], opts: RunOptions = {}): Promise { + const out = await run(cmd, opts) + return { + ...out, + text: out.stdout.toString(), + } +} + +export async function lines(cmd: string[], opts: RunOptions = {}): Promise { + return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean) +} + +export * as Process from "./process" diff --git a/packages/opencode/src/util/proxy-env.ts b/packages/opencode/src/util/proxy-env.ts new file mode 100644 index 0000000000000000000000000000000000000000..6682b3ca1390aa2b8d2e6cef026ec04cb4b668cf --- /dev/null +++ b/packages/opencode/src/util/proxy-env.ts @@ -0,0 +1,72 @@ +/* + * Adapted from proxy-from-env: https://github.com/Rob--W/proxy-from-env + * + * The MIT License + * + * Copyright (C) 2016-2018 Rob Wu + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const DEFAULT_PORTS: Record = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443, +} + +export function getProxyForUrl(input: string | URL) { + const url = typeof input === "string" ? (URL.canParse(input) ? new URL(input) : undefined) : input + if (!url) return + + const protocol = url.protocol.split(":", 1)[0] + const hostname = url.host.replace(/:\d*$/, "") + const port = Number.parseInt(url.port) || DEFAULT_PORTS[protocol] || 0 + if (!shouldProxy(hostname, port)) return + + const proxy = env(`${protocol}_proxy`) || env("all_proxy") + if (!proxy) return + return proxy.includes("://") ? proxy : `${protocol}://${proxy}` +} + +function shouldProxy(hostname: string, port: number) { + const noProxy = env("no_proxy").toLowerCase() + if (!noProxy) return true + if (noProxy === "*") return false + + return noProxy.split(/[,\s]/).every((proxy) => { + if (!proxy) return true + + const parsed = proxy.match(/^(.+):(\d+)$/) + const proxyHostname = parsed ? parsed[1] : proxy + const proxyPort = parsed ? Number.parseInt(parsed[2]) : 0 + if (proxyPort && proxyPort !== port) return true + + if (!/^[.*]/.test(proxyHostname)) return hostname !== proxyHostname + return !hostname.endsWith(proxyHostname.startsWith("*") ? proxyHostname.slice(1) : proxyHostname) + }) +} + +function env(key: string) { + return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || "" +} + +export * as ProxyEnv from "./proxy-env" diff --git a/packages/opencode/src/util/queue.ts b/packages/opencode/src/util/queue.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1af53fe8f095b42a32d6e985453d723a2eac1f3 --- /dev/null +++ b/packages/opencode/src/util/queue.ts @@ -0,0 +1,32 @@ +export class AsyncQueue implements AsyncIterable { + private queue: T[] = [] + private resolvers: ((value: T) => void)[] = [] + + push(item: T) { + const resolve = this.resolvers.shift() + if (resolve) resolve(item) + else this.queue.push(item) + } + + async next(): Promise { + if (this.queue.length > 0) return this.queue.shift()! + return new Promise((resolve) => this.resolvers.push(resolve)) + } + + async *[Symbol.asyncIterator]() { + while (true) yield await this.next() + } +} + +export async function work(concurrency: number, items: T[], fn: (item: T) => Promise) { + const pending = [...items] + await Promise.all( + Array.from({ length: concurrency }, async () => { + while (true) { + const item = pending.pop() + if (item === undefined) return + await fn(item) + } + }), + ) +} diff --git a/packages/opencode/src/util/record.ts b/packages/opencode/src/util/record.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2026a1048f2587b1dfe6a65b5e524e05c8f1f3f --- /dev/null +++ b/packages/opencode/src/util/record.ts @@ -0,0 +1 @@ +export * from "@opencode-ai/tui/util/record" diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfeee4322ad9db29289b48ca86d89ca3450a290d --- /dev/null +++ b/packages/opencode/src/util/repository.ts @@ -0,0 +1,232 @@ +import path from "path" +import { fileURLToPath } from "url" +import { Schema } from "effect" +import { Global } from "@opencode-ai/core/global" + +type BaseReference = { + host: string + path: string + segments: string[] + owner?: string + repo: string + remote: string + label: string +} + +export type RemoteReference = BaseReference & { + protocol?: string +} + +export type FileReference = BaseReference & { + host: "file" + protocol: "file:" +} + +export type Reference = RemoteReference | FileReference + +export class InvalidRepositoryReferenceError extends Schema.TaggedErrorClass()( + "RepositoryInvalidReferenceError", + { + repository: Schema.String, + message: Schema.String, + }, +) {} + +export class UnsupportedLocalRepositoryError extends Schema.TaggedErrorClass()( + "RepositoryUnsupportedLocalRepositoryError", + { + repository: Schema.String, + message: Schema.String, + }, +) {} + +export class InvalidRepositoryBranchError extends Schema.TaggedErrorClass()( + "RepositoryInvalidBranchError", + { + branch: Schema.String, + message: Schema.String, + }, +) {} + +export type RepositoryError = + | InvalidRepositoryReferenceError + | UnsupportedLocalRepositoryError + | InvalidRepositoryBranchError + +export function isRepositoryError(error: unknown): error is RepositoryError { + return ( + error instanceof InvalidRepositoryReferenceError || + error instanceof UnsupportedLocalRepositoryError || + error instanceof InvalidRepositoryBranchError + ) +} + +function normalizeRepositoryInput(input: string) { + return input + .trim() + .replace(/^git\+/, "") + .replace(/#.*$/, "") + .replace(/\/+$/, "") +} + +function trimGitSuffix(input: string) { + return input.replace(/\.git$/, "") +} + +function parts(input: string) { + return input + .split("/") + .map((item) => trimGitSuffix(item.trim())) + .filter(Boolean) +} + +function safeHost(input: string) { + return Boolean(input) && !input.startsWith("-") && !/[\s/\\]/.test(input) +} + +function safeSegment(input: string) { + return input !== "." && input !== ".." && !input.includes(":") && !/[\s/\\]/.test(input) +} + +function hostLike(input: string) { + return input.includes(".") || input.includes(":") || input === "localhost" +} + +function withSlash(input: string) { + return input.endsWith("/") ? input : `${input}/` +} + +function githubRemote(pathname: string) { + const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + if (!base) return `https://github.com/${pathname}.git` + return new URL(`${pathname}.git`, withSlash(base)).href +} + +function buildRemoteReference(input: { host: string; segments: string[]; remote?: string; protocol?: string }) { + const segments = input.segments.map(trimGitSuffix).filter(Boolean) + if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) return null + const pathname = segments.join("/") + const repo = segments[segments.length - 1] + const host = input.host.toLowerCase() + return { + host, + path: pathname, + segments, + owner: segments.length === 2 ? segments[0] : undefined, + repo, + remote: input.remote ?? (host === "github.com" ? githubRemote(pathname) : `https://${host}/${pathname}.git`), + label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`, + protocol: input.protocol, + } satisfies RemoteReference +} + +function buildFileReference(input: { url: URL; remote: string }) { + const filePath = path.normalize(fileURLToPath(input.url)) + const segments = filePath.split(/[\\/]+/).filter(Boolean) + if (!segments.length) return null + return { + host: "file", + path: filePath, + segments: segments.map((segment) => segment.replace(/:$/, "")), + owner: undefined, + repo: trimGitSuffix(segments[segments.length - 1]), + remote: input.remote, + label: filePath, + protocol: "file:", + } satisfies FileReference +} + +export function parseRepositoryReference(input: string) { + const cleaned = normalizeRepositoryInput(input) + if (!cleaned) return null + + const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/) + if (githubPrefixed) { + return buildRemoteReference({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] }) + } + + if (!cleaned.includes("://")) { + const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/) + if (scp) return buildRemoteReference({ host: scp[1], segments: parts(scp[2]), remote: cleaned }) + + const direct = parts(cleaned) + if (direct.length >= 2 && hostLike(direct[0])) { + return buildRemoteReference({ host: direct[0], segments: direct.slice(1) }) + } + + if (direct.length === 2) { + return buildRemoteReference({ host: "github.com", segments: direct }) + } + } + + try { + const url = new URL(cleaned) + if (url.protocol === "file:") return buildFileReference({ url, remote: cleaned }) + const pathname = parts(url.pathname) + const host = url.host + return buildRemoteReference({ + host, + segments: pathname, + remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned, + protocol: url.protocol, + }) + } catch { + return null + } +} + +export function isFileRepositoryReference(reference: Reference): reference is FileReference { + return reference.protocol === "file:" +} + +export function isRemoteRepositoryReference(reference: Reference): reference is RemoteReference { + return !isFileRepositoryReference(reference) +} + +export function parseRemoteRepositoryReference(input: string) { + const reference = parseRepositoryReference(input) + if (!reference) { + throw new InvalidRepositoryReferenceError({ + repository: input, + message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand", + }) + } + if (!isRemoteRepositoryReference(reference)) { + throw new UnsupportedLocalRepositoryError({ + repository: input, + message: "Local file repositories are not supported", + }) + } + return reference +} + +export function validateRepositoryBranch(branch: string) { + if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { + throw new InvalidRepositoryBranchError({ + branch, + message: + "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", + }) + } +} + +export function parseGitHubRemote(input: string) { + const cleaned = normalizeRepositoryInput(input) + if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null + + const parsed = parseRepositoryReference(cleaned) + if (!parsed || parsed.host !== "github.com" || !parsed.owner || parsed.segments.length !== 2) return null + return { owner: parsed.owner, repo: parsed.repo } +} + +export function repositoryCachePath(input: Reference) { + return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments) +} + +export function repositoryCacheIdentity(input: Reference) { + return `${input.host}/${input.path}` +} + +export function sameRepositoryReference(left: Reference, right: Reference) { + return repositoryCacheIdentity(left) === repositoryCacheIdentity(right) +} diff --git a/packages/opencode/src/util/rpc.ts b/packages/opencode/src/util/rpc.ts new file mode 100644 index 0000000000000000000000000000000000000000..02586ebcfc609253ebb762182b19f55bb1c7914d --- /dev/null +++ b/packages/opencode/src/util/rpc.ts @@ -0,0 +1,66 @@ +type Definition = { + [method: string]: (input: any) => any +} + +export function listen(rpc: Definition) { + onmessage = async (evt) => { + const parsed = JSON.parse(evt.data) + if (parsed.type === "rpc.request") { + const result = await rpc[parsed.method](parsed.input) + postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id })) + } + } +} + +export function emit(event: string, data: unknown) { + postMessage(JSON.stringify({ type: "rpc.event", event, data })) +} + +export function client(target: { + postMessage: (data: string) => void | null + onmessage: ((this: Worker, ev: MessageEvent) => any) | null +}) { + const pending = new Map void>() + const listeners = new Map void>>() + let id = 0 + target.onmessage = async (evt) => { + const parsed = JSON.parse(evt.data) + if (parsed.type === "rpc.result") { + const resolve = pending.get(parsed.id) + if (resolve) { + resolve(parsed.result) + pending.delete(parsed.id) + } + } + if (parsed.type === "rpc.event") { + const handlers = listeners.get(parsed.event) + if (handlers) { + for (const handler of handlers) { + handler(parsed.data) + } + } + } + } + return { + call(method: Method, input: Parameters[0]): Promise> { + const requestId = id++ + return new Promise((resolve) => { + pending.set(requestId, resolve) + target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) + }) + }, + on(event: string, handler: (data: Data) => void) { + let handlers = listeners.get(event) + if (!handlers) { + handlers = new Set() + listeners.set(event, handlers) + } + handlers.add(handler) + return () => { + handlers!.delete(handler) + } + }, + } +} + +export * as Rpc from "./rpc" diff --git a/packages/opencode/src/util/signal.ts b/packages/opencode/src/util/signal.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc633ecc60fb095a8e124a3c728956e4e98c6053 --- /dev/null +++ b/packages/opencode/src/util/signal.ts @@ -0,0 +1,12 @@ +export function signal() { + let resolve: any + const promise = new Promise((r) => (resolve = r)) + return { + trigger() { + return resolve() + }, + wait() { + return promise + }, + } +} diff --git a/packages/opencode/src/util/timeout.ts b/packages/opencode/src/util/timeout.ts new file mode 100644 index 0000000000000000000000000000000000000000..22f2648c92b530f0a8aa9e8bc1c8602eb79ab294 --- /dev/null +++ b/packages/opencode/src/util/timeout.ts @@ -0,0 +1,13 @@ +export function withTimeout(promise: Promise, ms: number, label?: string): Promise { + let timeout: NodeJS.Timeout + return Promise.race([ + promise.finally(() => { + clearTimeout(timeout) + }), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(label ?? `Operation timed out after ${ms}ms`)) + }, ms) + }), + ]) +} diff --git a/packages/opencode/src/util/token.ts b/packages/opencode/src/util/token.ts new file mode 100644 index 0000000000000000000000000000000000000000..d45a51346b1e404f0ba39ed6cb866926d81e51ec --- /dev/null +++ b/packages/opencode/src/util/token.ts @@ -0,0 +1 @@ +export { Token, estimate } from "@opencode-ai/core/util/token" diff --git a/packages/opencode/src/util/wildcard.ts b/packages/opencode/src/util/wildcard.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2affbf8499887f528b6c326c30280d5bab64d18 --- /dev/null +++ b/packages/opencode/src/util/wildcard.ts @@ -0,0 +1,59 @@ +import { sortBy, pipe } from "remeda" + +export function match(str: string, pattern: string) { + if (str) str = str.replaceAll("\\", "/") + if (pattern) pattern = pattern.replaceAll("\\", "/") + let escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape special regex chars + .replace(/\*/g, ".*") // * becomes .* + .replace(/\?/g, ".") // ? becomes . + + // If pattern ends with " *" (space + wildcard), make the trailing part optional + // This allows "ls *" to match both "ls" and "ls -la" + if (escaped.endsWith(" .*")) { + escaped = escaped.slice(0, -3) + "( .*)?" + } + + const flags = process.platform === "win32" ? "si" : "s" + return new RegExp("^" + escaped + "$", flags).test(str) +} + +export function all(input: string, patterns: Record) { + const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"])) + let result = undefined + for (const [pattern, value] of sorted) { + if (match(input, pattern)) { + result = value + continue + } + } + return result +} + +export function allStructured(input: { head: string; tail: string[] }, patterns: Record) { + const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"])) + let result = undefined + for (const [pattern, value] of sorted) { + const parts = pattern.split(/\s+/) + if (!match(input.head, parts[0])) continue + if (parts.length === 1 || matchSequence(input.tail, parts.slice(1))) { + result = value + continue + } + } + return result +} + +function matchSequence(items: string[], patterns: string[]): boolean { + if (patterns.length === 0) return true + const [pattern, ...rest] = patterns + if (pattern === "*") return matchSequence(items, rest) + for (let i = 0; i < items.length; i++) { + if (match(items[i], pattern) && matchSequence(items.slice(i + 1), rest)) { + return true + } + } + return false +} + +export * as Wildcard from "./wildcard" diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0c43ed2c3409b9dcf2ef7b8b08cef49d8e39c75 --- /dev/null +++ b/packages/opencode/src/worktree/index.ts @@ -0,0 +1,623 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { path } from "@opencode-ai/core/effect/app-node-platform" +import { Global } from "@opencode-ai/core/global" +import { InstanceStore } from "@/project/instance-store" +import { Project } from "@/project/project" +import { Database } from "@opencode-ai/core/database/database" +import { eq } from "drizzle-orm" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import type { ProjectV2 } from "@opencode-ai/core/project" +import { Slug } from "@opencode-ai/core/util/slug" +import { errorMessage } from "../util/error" +import { GlobalBus } from "@/bus/global" +import { Git } from "@/git" +import { Effect, Layer, Path, Schema, Scope, Context } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppProcess } from "@opencode-ai/core/process" +import { InstanceState } from "@/effect/instance-state" +import { WorktreeEvent } from "@opencode-ai/schema/worktree-event" + +export const Event = WorktreeEvent + +export const Info = Schema.Struct({ + name: Schema.String, + branch: Schema.optional(Schema.String), + directory: Schema.String, +}).annotate({ identifier: "Worktree" }) +export type Info = Schema.Schema.Type + +export const CreateInput = Schema.Struct({ + name: Schema.optional(Schema.String), + startCommand: Schema.optional( + Schema.String.annotate({ description: "Additional startup script to run after the project's start command" }), + ), +}).annotate({ identifier: "WorktreeCreateInput" }) +export type CreateInput = Schema.Schema.Type + +export const RemoveInput = Schema.Struct({ + directory: Schema.String, +}).annotate({ identifier: "WorktreeRemoveInput" }) +export type RemoveInput = Schema.Schema.Type + +export const ResetInput = Schema.Struct({ + directory: Schema.String, +}).annotate({ identifier: "WorktreeResetInput" }) +export type ResetInput = Schema.Schema.Type + +export class NotGitError extends Schema.TaggedErrorClass()("WorktreeNotGitError", { + message: Schema.String, +}) {} + +export class NameGenerationFailedError extends Schema.TaggedErrorClass()( + "WorktreeNameGenerationFailedError", + { + message: Schema.String, + }, +) {} + +export class CreateFailedError extends Schema.TaggedErrorClass()("WorktreeCreateFailedError", { + message: Schema.String, +}) {} + +export class StartCommandFailedError extends Schema.TaggedErrorClass()( + "WorktreeStartCommandFailedError", + { + message: Schema.String, + }, +) {} + +export class RemoveFailedError extends Schema.TaggedErrorClass()("WorktreeRemoveFailedError", { + message: Schema.String, +}) {} + +export class ResetFailedError extends Schema.TaggedErrorClass()("WorktreeResetFailedError", { + message: Schema.String, +}) {} + +export class ListFailedError extends Schema.TaggedErrorClass()("WorktreeListFailedError", { + message: Schema.String, +}) {} + +export type Error = + | NotGitError + | NameGenerationFailedError + | CreateFailedError + | StartCommandFailedError + | RemoveFailedError + | ResetFailedError + | ListFailedError + +function slugify(input: string) { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") +} + +function failedRemoves(...chunks: string[]) { + return chunks.filter(Boolean).flatMap((chunk) => + chunk + .split("\n") + .map((line) => line.trim()) + .flatMap((line) => { + const match = line.match(/^warning:\s+failed to remove\s+(.+):\s+/i) + if (!match) return [] + const value = match[1]?.trim().replace(/^['"]|['"]$/g, "") + if (!value) return [] + return [value] + }), + ) +} + +// --------------------------------------------------------------------------- +// Effect service +// --------------------------------------------------------------------------- + +export interface Interface { + readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect + readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect + readonly create: (input?: CreateInput) => Effect.Effect + readonly list: () => Effect.Effect<(Omit & { branch?: string })[], Error> + readonly remove: (input: RemoveInput) => Effect.Effect + readonly reset: (input: ResetInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Worktree") {} + +type GitResult = { code: number; text: string; stderr: string } + +const layer: Layer.Layer< + Service, + never, + | FSUtil.Service + | Path.Path + | AppProcess.Service + | Git.Service + | Project.Service + | InstanceStore.Service + | Database.Service +> = Layer.effect( + Service, + Effect.gen(function* () { + const scope = yield* Scope.Scope + const fs = yield* FSUtil.Service + const pathSvc = yield* Path.Path + const appProcess = yield* AppProcess.Service + const { db } = yield* Database.Service + const gitSvc = yield* Git.Service + const project = yield* Project.Service + const store = yield* InstanceStore.Service + + const git = Effect.fnUntraced( + function* (args: string[], opts?: { cwd?: string }) { + const result = yield* appProcess.run( + ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }), + ) + return { + code: result.exitCode, + text: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + } satisfies GitResult + }, + Effect.catch((e) => + Effect.succeed({ + code: 1, + text: "", + stderr: e instanceof Error ? e.message : String(e), + } satisfies GitResult), + ), + ) + + const MAX_NAME_ATTEMPTS = 26 + const candidate = Effect.fn("Worktree.candidate")(function* (input: { + root: string + name?: string + detached?: boolean + }) { + const ctx = yield* InstanceState.context + for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) { + const name = input.name ? (attempt === 0 ? input.name : `${input.name}-${Slug.create()}`) : Slug.create() + const branch = input.detached ? undefined : `opencode/${name}` + const directory = pathSvc.join(input.root, name) + + if (yield* fs.exists(directory).pipe(Effect.orDie)) continue + + if (branch) { + const ref = `refs/heads/${branch}` + const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree }) + if (branchCheck.code === 0) continue + } + + return { name, directory, ...(branch ? { branch } : {}) } + } + return yield* new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) + }) + + const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (input?: { + name?: string + detached?: boolean + }) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { + return yield* new NotGitError({ message: "Worktrees are only supported for git projects" }) + } + + const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id) + yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie) + + return yield* candidate({ root, name: input?.name ? slugify(input.name) : "", detached: input?.detached }) + }) + + const setup = Effect.fnUntraced(function* (info: Info) { + const ctx = yield* InstanceState.context + const created = yield* git( + info.branch + ? ["worktree", "add", "--no-checkout", "-b", info.branch, info.directory] + : ["worktree", "add", "--no-checkout", "--detach", info.directory, "HEAD"], + { cwd: ctx.worktree }, + ) + if (created.code !== 0) { + return yield* new CreateFailedError({ + message: created.stderr || created.text || "Failed to create git worktree", + }) + } + + yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void)) + }) + + const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) { + const ctx = yield* InstanceState.context + const workspaceID = yield* InstanceState.workspaceID + const projectID = ctx.project.id + const extra = startCommand?.trim() + + const populated = yield* git(["reset", "--hard"], { cwd: info.directory }) + if (populated.code !== 0) { + const message = populated.stderr || populated.text || "Failed to populate worktree" + yield* Effect.logError("worktree checkout failed", { directory: info.directory, message }) + GlobalBus.emit("event", { + directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, + payload: { type: Event.Failed.type, properties: { message } }, + }) + return + } + + const booted = yield* store.load({ directory: info.directory }).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.gen(function* () { + const message = errorMessage(error) + yield* Effect.logError("worktree bootstrap failed", { directory: info.directory, message }) + GlobalBus.emit("event", { + directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, + payload: { type: Event.Failed.type, properties: { message } }, + }) + return false + }), + ), + ) + if (!booted) return + + GlobalBus.emit("event", { + directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, + payload: { + type: Event.Ready.type, + properties: { name: info.name, ...(info.branch ? { branch: info.branch } : {}) }, + }, + }) + + yield* runStartScripts(info.directory, { projectID, extra }) + }) + + const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { + yield* setup(info) + yield* boot(info, startCommand).pipe( + Effect.catchCause((cause) => Effect.logError("worktree bootstrap failed", { cause })), + Effect.forkIn(scope), + ) + }) + + const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) { + const info = yield* makeWorktreeInfo({ name: input?.name }) + yield* createFromInfo(info, input?.startCommand) + return info + }) + + const canonical = Effect.fnUntraced(function* (input: string) { + const abs = pathSvc.resolve(input) + const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs))) + const normalized = pathSvc.normalize(real) + return process.platform === "win32" ? normalized.toLowerCase() : normalized + }) + + function parseWorktreeList(text: string) { + return text + .split("\n") + .map((line) => line.trim()) + .reduce<{ path?: string; branch?: string }[]>((acc, line) => { + if (!line) return acc + if (line.startsWith("worktree ")) { + acc.push({ path: line.slice("worktree ".length).trim() }) + return acc + } + const current = acc[acc.length - 1] + if (!current) return acc + if (line.startsWith("branch ")) { + current.branch = line.slice("branch ".length).trim() + } + return acc + }, []) + } + + const locateWorktree = Effect.fnUntraced(function* ( + entries: { path?: string; branch?: string }[], + directory: string, + ) { + for (const item of entries) { + if (!item.path) continue + const key = yield* canonical(item.path) + if (key === directory) return item + } + return undefined + }) + + const list = Effect.fn("Worktree.list")(function* () { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { + return [] + } + + const result = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) + if (result.code !== 0) { + return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" }) + } + + const primary = yield* canonical(ctx.project.worktree) + const primaryName = pathSvc.basename(primary).toLowerCase() + return yield* Effect.forEach(parseWorktreeList(result.text), (entry) => + Effect.gen(function* () { + if (!entry.path) return undefined + const directory = yield* canonical(entry.path) + if (directory === primary) return undefined + const name = pathSvc.basename(directory).toLowerCase() + return { + name: name === primaryName ? pathSvc.basename(pathSvc.dirname(directory)) : name, + directory, + ...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}), + } + }), + ).pipe(Effect.map((items) => items.filter((item) => item !== undefined))) + }) + + function stopFsmonitor(target: string) { + return fs.exists(target).pipe( + Effect.orDie, + Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)), + ) + } + + function cleanDirectory(target: string) { + return Effect.tryPromise({ + try: async () => { + const fsp = await import("fs/promises") + const attempts = process.platform === "win32" ? 50 : 5 + for (const attempt of Array.from({ length: attempts }, (_, i) => i)) { + try { + await fsp.rm(target, { recursive: true, force: true }) + return + } catch (error) { + if (attempt === attempts - 1) throw error + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + }, + catch: (error) => + new RemoveFailedError({ message: errorMessage(error) || "Failed to remove git worktree directory" }), + }) + } + + const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { + return yield* new NotGitError({ message: "Worktrees are only supported for git projects" }) + } + + const directory = yield* canonical(input.directory) + + // Preserve the loaded path casing for the store cache; `directory` is lowercased on Windows. + if (directory !== (yield* canonical(ctx.worktree))) yield* store.disposeDirectory(input.directory) + + const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) + if (list.code !== 0) { + return yield* new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) + } + + const entries = parseWorktreeList(list.text) + const entry = yield* locateWorktree(entries, directory) + + if (!entry?.path) { + const directoryExists = yield* fs.exists(directory).pipe(Effect.orDie) + if (directoryExists) { + yield* stopFsmonitor(directory) + yield* cleanDirectory(directory) + } + return true + } + + // Git may return the original casing when a caller supplied a normalized Windows path. + yield* store.disposeDirectory(entry.path) + yield* stopFsmonitor(entry.path) + const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: ctx.worktree }) + if (removed.code !== 0) { + const next = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) + if (next.code !== 0) { + return yield* new RemoveFailedError({ + message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree", + }) + } + + const stale = yield* locateWorktree(parseWorktreeList(next.text), directory) + if (stale?.path) { + return yield* new RemoveFailedError({ + message: removed.stderr || removed.text || "Failed to remove git worktree", + }) + } + } + + yield* cleanDirectory(entry.path) + + const branch = entry.branch?.replace(/^refs\/heads\//, "") + if (branch) { + const deleted = yield* git(["branch", "-D", branch], { cwd: ctx.worktree }) + if (deleted.code !== 0) { + return yield* new RemoveFailedError({ + message: deleted.stderr || deleted.text || "Failed to delete worktree branch", + }) + } + } + + return true + }) + + const gitExpect = Effect.fnUntraced(function* ( + args: string[], + opts: { cwd: string }, + error: (r: GitResult) => Error, + ) { + const result = yield* git(args, opts) + if (result.code !== 0) return yield* error(result) + return result + }) + + const runStartCommand = Effect.fnUntraced( + function* (directory: string, cmd: string) { + const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]] + const result = yield* appProcess.run( + ChildProcess.make(shell, args as string[], { cwd: directory, extendEnv: true, stdin: "ignore" }), + ) + return { code: result.exitCode, stderr: result.stderr.toString("utf8") } + }, + Effect.catch(() => Effect.succeed({ code: 1, stderr: "" })), + ) + + const runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) { + const text = cmd.trim() + if (!text) return true + const result = yield* runStartCommand(directory, text) + if (result.code === 0) return true + yield* Effect.logError("worktree start command failed", { kind, directory, message: result.stderr }) + return false + }) + + const runStartScripts = Effect.fnUntraced(function* ( + directory: string, + input: { projectID: ProjectV2.ID; extra?: string }, + ) { + const row = yield* db + .select() + .from(ProjectTable) + .where(eq(ProjectTable.id, input.projectID)) + .get() + .pipe(Effect.orDie) + const project = row ? Project.fromRow(row) : undefined + const startup = project?.commands?.start?.trim() ?? "" + const ok = yield* runStartScript(directory, startup, "project") + if (!ok) return false + yield* runStartScript(directory, input.extra ?? "", "worktree") + return true + }) + + const prune = Effect.fnUntraced(function* (root: string, entries: string[]) { + const base = yield* canonical(root) + yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const target = yield* canonical(pathSvc.resolve(root, entry)) + if (target === base) return + if (!target.startsWith(`${base}${pathSvc.sep}`)) return + yield* fs.remove(target, { recursive: true }).pipe(Effect.ignore) + }), + { concurrency: "unbounded" }, + ) + }) + + const sweep = Effect.fnUntraced(function* (root: string) { + const first = yield* git(["clean", "-ffdx"], { cwd: root }) + if (first.code === 0) return first + + const entries = failedRemoves(first.stderr, first.text) + if (!entries.length) return first + + yield* prune(root, entries) + return yield* git(["clean", "-ffdx"], { cwd: root }) + }) + + const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { + return yield* new NotGitError({ message: "Worktrees are only supported for git projects" }) + } + + const directory = yield* canonical(input.directory) + const primary = yield* canonical(ctx.worktree) + if (directory === primary) { + return yield* new ResetFailedError({ message: "Cannot reset the primary workspace" }) + } + + const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) + if (list.code !== 0) { + return yield* new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) + } + + const entry = yield* locateWorktree(parseWorktreeList(list.text), directory) + if (!entry?.path) { + return yield* new ResetFailedError({ message: "Worktree not found" }) + } + + const worktreePath = entry.path + + const base = yield* gitSvc.defaultBranch(ctx.worktree) + if (!base) { + return yield* new ResetFailedError({ message: "Default branch not found" }) + } + + const sep = base.ref.indexOf("/") + if (base.ref !== base.name && sep > 0) { + const remote = base.ref.slice(0, sep) + const branch = base.ref.slice(sep + 1) + yield* gitExpect( + ["fetch", remote, branch], + { cwd: ctx.worktree }, + (r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }), + ) + } + + yield* gitExpect( + ["reset", "--hard", base.ref], + { cwd: worktreePath }, + (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }), + ) + + const cleanResult = yield* sweep(worktreePath) + if (cleanResult.code !== 0) { + return yield* new ResetFailedError({ + message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree", + }) + } + + yield* gitExpect( + ["submodule", "update", "--init", "--recursive", "--force"], + { cwd: worktreePath }, + (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to update submodules" }), + ) + + yield* gitExpect( + ["submodule", "foreach", "--recursive", "git", "reset", "--hard"], + { cwd: worktreePath }, + (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset submodules" }), + ) + + yield* gitExpect( + ["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], + { cwd: worktreePath }, + (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to clean submodules" }), + ) + + const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) + if (status.code !== 0) { + return yield* new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" }) + } + + if (status.text.trim()) { + return yield* new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` }) + } + + yield* runStartScripts(worktreePath, { projectID: ctx.project.id }).pipe( + Effect.catchCause((cause) => Effect.logError("worktree start task failed", { cause })), + Effect.forkIn(scope), + ) + + return true + }) + + return Service.of({ makeWorktreeInfo, createFromInfo, create, list, remove, reset }) + }), +) + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [FSUtil.node, path, AppProcess.node, Git.node, Project.node, InstanceStore.node, Database.node], +}) + +export * as Worktree from "." diff --git a/packages/opencode/test/AGENTS.md b/packages/opencode/test/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..464b75cd82f050585ebe64974337fbcc1c891601 --- /dev/null +++ b/packages/opencode/test/AGENTS.md @@ -0,0 +1,204 @@ +# Test Fixtures Guide + +## Temporary Directory Fixture + +The `tmpdir` function in `fixture/fixture.ts` creates temporary directories for tests with automatic cleanup. + +### Basic Usage + +```typescript +import { tmpdir } from "./fixture/fixture" + +test("example", async () => { + await using tmp = await tmpdir() + // tmp.path is the temp directory path + // automatically cleaned up when test ends +}) +``` + +### Options + +- `git?: boolean` - Initialize a git repo with a root commit +- `config?: Partial` - Write an `opencode.json` config file +- `init?: (dir: string) => Promise` - Custom setup function, returns value accessible as `tmp.extra` +- `dispose?: (dir: string) => Promise` - Custom cleanup function + +### Examples + +**Git repository:** + +```typescript +await using tmp = await tmpdir({ git: true }) +``` + +**With config file:** + +```typescript +await using tmp = await tmpdir({ + config: { model: "test/model", username: "testuser" }, +}) +``` + +**Custom initialization (returns extra data):** + +```typescript +await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "file.txt"), "content") + return "extra data" + }, +}) +// Access extra data via tmp.extra +console.log(tmp.extra) // "extra data" +``` + +**With cleanup:** + +```typescript +await using tmp = await tmpdir({ + init: async (dir) => { + const specialDir = path.join(dir, "special") + await fs.mkdir(specialDir) + return specialDir + }, + dispose: async (dir) => { + // Custom cleanup logic + await fs.rm(path.join(dir, "special"), { recursive: true }) + }, +}) +``` + +### Returned Object + +- `path: string` - Absolute path to the temp directory (realpath resolved) +- `extra: T` - Value returned by the `init` function +- `[Symbol.asyncDispose]` - Enables automatic cleanup via `await using` + +### Notes + +- Directories are created in the system temp folder with prefix `opencode-test-` +- Use `await using` for automatic cleanup when the variable goes out of scope +- Paths are sanitized to strip null bytes (defensive fix for CI environments) + +## Testing With Effects + +Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect services or Effect-based workflows. + +### Core Pattern + +```typescript +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(MyService.defaultLayer)) + +describe("my service", () => { + it.instance("does the thing", () => + Effect.gen(function* () { + const svc = yield* MyService.Service + const out = yield* svc.run() + expect(out).toEqual("ok") + }), + ) +}) +``` + +### `it.effect` vs `it.live` + +- Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`. +- Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior. +- Use `it.instance(...)` for live Effect tests that need a scoped temporary directory and instance context. +- Most integration-style tests in this package use `it.live(...)`. + +### Effect Fixtures + +Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a manual runtime in each test. + +- `tmpdirScoped(options?)` creates a scoped temp directory and cleans it up when the Effect scope closes. +- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it runs an Effect with `InstanceRef` provided for `dir`. +- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup. +- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server. + +Use `it.instance(...)` by default when a test only needs one temp instance. Yield `TestInstance` from `fixture/fixture.ts` when the test needs the temp directory path: + +```typescript +import { TestInstance } from "../fixture/fixture" + +it.instance("uses the temp directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + expect(test.directory).toContain("opencode-test-") + }), +) +``` + +Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, needs to switch instance context within one test, or explicitly tests instance disposal/reload lifetime. + +### Style + +- Define `const it = testEffect(...)` near the top of the file. +- Keep the test body inside `Effect.gen(function* () { ... })`. +- Yield services directly with `yield* MyService.Service` or `yield* MyTool`. +- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime. +- When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests. + +### Partial Service Stubs + +When a test only needs to override one or two methods of a service, prefer `Layer.mock` over a hand-rolled `Layer.succeed(Service, Service.of({ ... }))`. `Layer.mock` lets you supply just the methods that matter — anything else throws an `UnimplementedError` defect if the test accidentally calls it, which is exactly the signal you want. + +```typescript +import { Effect, Layer } from "effect" +import { Account } from "@/account/account" + +const failingAccountLayer = Layer.mock(Account.Service, { + orgsByAccount: () => Effect.fail(new Account.AccountServiceError({ message: "simulated upstream failure" })), +}) +``` + +This is much shorter than stubbing every method with `Effect.void` / `Effect.succeed(...)` placeholders, and it keeps the test focused on the behaviour under test. + +## Synchronizing With Concurrent Work + +### The Anti-Pattern + +Using `Effect.sleep(N)` or `setTimeout` as a "wait for the forked fiber to be ready" hack races the scheduler. The forked fiber may not have reached the synchronization point within `N` ms on a slow CI host, and the test fails intermittently. See PR #27622 for a concrete flake that fell out of this exact pattern. + +### The Fix + +Wait on a **published readiness signal**, not wall-clock time. Available affordances: + +- `pollWithTimeout(effect, message, duration?)` from `test/lib/effect.ts` — repeatedly run a predicate effect until it returns a non-`undefined` value, with a timeout. +- `awaitWithTimeout(effect, message, duration?)` from `test/lib/effect.ts` — wrap any effect with `Effect.timeoutOrElse` and a custom error message. +- `llm.wait(n)` from `test/lib/llm-server.ts` — wait until the mock LLM has received `n` HTTP calls. +- `SessionStatus.Service` `.get(sessionID)` — observable per-session state (`{ type: "busy" | "idle" | ... }`). +- `BackgroundJob.wait({ id, timeout })` from `src/background/job.ts` — wait for a background job to complete. +- Bus subscriptions — fork `Stream.runForEach(bus.subscribe(Event), ...)` and open a `Latch` inside the callback to signal first-event readiness. +- `Deferred.await(deferred).pipe(Effect.timeoutOrElse(...))` for one-shot signals. + +### Example + +```ts +// Antipattern — race +yield * prompt.shell({ command: "sleep 30" }).pipe(Effect.forkChild) +yield * Effect.sleep(50) +yield * prompt.cancel(chat.id) + +// Fix — wait for a published readiness signal +yield * prompt.shell({ command: "sleep 30" }).pipe(Effect.forkChild) +yield * + pollWithTimeout( + Effect.gen(function* () { + const s = yield* (yield* SessionStatus.Service).get(chat.id) + return s.type === "busy" ? (true as const) : undefined + }), + "session never became busy", + ) +yield * prompt.cancel(chat.id) +``` + +### When Fixed Sleeps Are OK + +- Testing debounce or throttle behavior, where the sleep **is** the test. +- Letting real wall-clock advance past a genuine timestamp resolution boundary (e.g. mtime granularity). +- Simulating network latency in race-regression tests that intentionally exercise ordering. diff --git a/packages/opencode/test/EFFECT_TEST_MIGRATION.md b/packages/opencode/test/EFFECT_TEST_MIGRATION.md new file mode 100644 index 0000000000000000000000000000000000000000..893aa922b76e864fdb01f000e16e27bcde89d02c --- /dev/null +++ b/packages/opencode/test/EFFECT_TEST_MIGRATION.md @@ -0,0 +1,169 @@ +# Effect Test Migration + +Move tests that exercise Effect services out of Promise-land and into the +shared `testEffect` pattern. + +This file is guidance, not a live inventory. Before claiming a migration, +search current `dev` for the exact anti-pattern and update any PR notes +with what you actually changed. + +## Target Pattern + +Every Effect service test should have one local runner near the top: + +```ts +const it = testEffect(layer) +``` + +Use the runner method that matches the behavior: + +```ts +it.effect("pure service behavior", () => + Effect.gen(function* () { + const service = yield* SomeService.Service + expect(yield* service.run()).toEqual("ok") + }), +) + +it.instance("instance-local behavior", () => + Effect.gen(function* () { + const test = yield* TestInstance + expect(test.directory).toContain("opencode-test-") + }), +) + +it.live("live filesystem or process behavior", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + // real clock / fs / git / process work + }), +) +``` + +## Choosing The Runner + +- `it.effect(...)` — pure Effect behavior with `TestClock` and + `TestConsole`. +- `it.instance(...)` — service behavior that needs one scoped opencode + instance. +- `it.live(...)` — real time, filesystem mtimes, child processes, git, + locks, servers, watchers, or OS behavior. + +Most integration-style tests use `it.live(...)` or `it.instance(...)`. + +## Layer Rules + +Compose tests from open service layers when a dependency needs replacing. +Do not use a closed `defaultLayer` and then try to override an inner +dependency after it has already been provided. + +Prefer small reusable fake boundary layers in `test/fake/*`: + +```ts +AuthTest.empty +AccountTest.empty +NpmTest.noop +SkillTest.empty +ProviderTest.fake().layer +``` + +Use `Layer.mock` for partial service stubs. Missing methods should fail +loudly if the test accidentally calls them. + +Do not add generic test-layer builders until repeated local compositions +prove the need. + +## Fixture Rules + +Use Effect-aware fixtures from `test/fixture/fixture.ts`: + +- `TestInstance` inside `it.instance(...)` for the current temp instance. +- `tmpdirScoped(...)` inside `Effect.gen` for extra temp directories. +- `provideInstance(dir)(effect)` when one test needs to switch instance + context. +- `provideTmpdirInstance((dir) => effect, options)` when a live test needs + custom instance setup or multiple instance scopes. +- `disposeAllInstances()` in `afterEach` only for integration tests that + intentionally touch shared instance registries. + +Avoid mutable global setup. If a global mutation is unavoidable during a +migration, scope it with acquire/release and treat it as temporary. + +Long term, tests should not toggle `process.env`, `Global.Path`, or +mutable flags when behavior can be modeled with services. Prefer layers +such as `RuntimeFlags.layer(...)` or focused fake services. + +## Anti-Patterns To Remove + +- `test(..., async () => Effect.runPromise(...))` +- local `run(...)`, `load(...)`, `svc(...)`, or `runtime.runPromise(...)` + wrappers that only provide a layer +- `tmpdir()` plus legacy instance provision in Promise test bodies +- custom `ManagedRuntime.make(...)` in test files +- Promise `try/catch` around Effect failures +- `Promise.withResolvers`, `Bun.sleep`, or `setTimeout` for synchronization + when events, `Deferred`, fibers, or deterministic state checks fit +- mutable env/global/flag changes after layers are built + +Promise helpers are acceptable at non-Effect boundaries, but yield them from +inside an Effect body with `Effect.promise(...)` rather than making them the +test harness. + +## Conversion Recipe + +1. Identify the real service under test and whether its open `layer` or + closed `defaultLayer` is appropriate. +2. Build one top-level `layer` with real dependencies where relevant and + fake layers at slow or external boundaries. +3. Replace local Promise wrappers with Effect helpers. +4. Convert `test(..., async () => { ... })` to `it.effect`, `it.instance`, + or `it.live`. +5. Move `await` calls inside `Effect.gen` as `yield*` calls. +6. Replace `await using tmp = await tmpdir(...)` with + `yield* tmpdirScoped(...)` when the temp directory lives inside the + Effect test. +7. Replace Promise failure assertions with `Effect.exit`, `Effect.flip`, or + focused assertion helpers. +8. Preserve concurrency with fibers, `Deferred`, and + `Effect.all(..., { concurrency: "unbounded" })`; do not accidentally + serialize formerly parallel behavior. +9. Run the focused test file and `bun typecheck` from `packages/opencode`. + +## Good Examples + +Use current examples as patterns, but re-check them before copying because +test migrations are active: + +- `test/effect/instance-state.test.ts` — scoped directories, instance + switching, disposal, and concurrency. +- `test/bus/bus-effect.test.ts` — `Deferred`, streams, scoped fibers. +- `test/agent/plugin-agent-regression.test.ts` — real service layers plus + fake boundary layers. +- `test/account/service.test.ts` — service-level live tests, typed errors, + fake HTTP clients. + +## Migration Queue Policy + +Do not maintain a long file checklist here. It goes stale quickly. + +When looking for the next target, search for current anti-patterns: + +```bash +git grep -n "Effect.runPromise\|ManagedRuntime\|Promise.withResolvers\|Bun.sleep\|withTestInstance" -- packages/opencode/test +``` + +Then choose one file or one small cluster, keep the PR focused, and mention +the focused verification in the PR body. + +## Rough Edges To Watch + +- Failure assertions against `Exit` / `Cause` can get verbose. Add helpers + only after the same shape repeats across multiple files. +- Some tests still need `Effect.promise(...)` around Node/Bun APIs. Prefer + Effect platform services when the surrounding code already uses them, but + do not block useful migrations on perfect abstraction. +- Layer composition can be noisy when a test needs real service subtrees plus + fake boundaries. Extract small `test/fake/*` layers before inventing + larger builders. +- Concurrency tests can get harder to read after replacing Promise + resolvers. Look for repeated patterns that deserve named helpers. diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..efeaf408a259bb192c62135bdb4ec4ee5ff361fb --- /dev/null +++ b/packages/opencode/test/agent/agent.test.ts @@ -0,0 +1,755 @@ +import { afterEach, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Cause, Effect, Exit, Layer } from "effect" +import path from "path" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { Agent } from "../../src/agent/agent" +import { Auth } from "../../src/auth" +import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" +import { Global } from "@opencode-ai/core/global" +import { Permission } from "../../src/permission" +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Plugin } from "../../src/plugin" +import { Provider } from "../../src/provider/provider" +import { Skill } from "../../src/skill" +import { Truncate } from "../../src/tool/truncate" + +const agentLayer = (flags: Partial = {}) => + LayerNode.compile( + LayerNode.group([Agent.node, Plugin.node, Provider.node, Auth.node, Config.node, Skill.node, RuntimeFlags.node]), + [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], + ) + +const it = testEffect(agentLayer()) + +// Helper to evaluate permission for a tool with wildcard pattern +function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionV1.Action | undefined { + if (!agent) return undefined + return Permission.evaluate(permission, "*", agent.permission).action +} + +function load(fn: (svc: Agent.Interface) => Effect.Effect) { + return Agent.Service.use(fn) +} + +const expectDefaultAgentError = Effect.fn("AgentTest.expectDefaultAgentError")(function* (message: string) { + const exit = yield* load((svc) => svc.defaultAgent()).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain(message) +}) + +afterEach(async () => { + await disposeAllInstances() +}) + +it.instance("returns default native agents when no config", () => + Effect.gen(function* () { + const agents = yield* load((svc) => svc.list()) + const names = agents.map((a) => a.name) + expect(names).toContain("build") + expect(names).toContain("plan") + expect(names).toContain("general") + expect(names).toContain("explore") + expect(names).toContain("compaction") + expect(names).toContain("title") + expect(names).toContain("summary") + }), +) + +it.instance("build agent has correct default properties", () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build).toBeDefined() + expect(build?.mode).toBe("primary") + expect(build?.native).toBe(true) + expect(evalPerm(build, "edit")).toBe("allow") + expect(evalPerm(build, "bash")).toBe("allow") + }), +) + +it.instance("plan agent denies edits except .opencode/plans/*", () => + Effect.gen(function* () { + const plan = yield* load((svc) => svc.get("plan")) + expect(plan).toBeDefined() + // Wildcard is denied + expect(evalPerm(plan, "edit")).toBe("deny") + // But specific path is allowed + expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") + }), +) + +it.instance("plan agent denies the general subagent by default", () => + Effect.gen(function* () { + const plan = yield* load((svc) => svc.get("plan")) + expect(plan).toBeDefined() + expect(Permission.evaluate("task", "general", plan!.permission).action).toBe("deny") + expect(Permission.evaluate("task", "explore", plan!.permission).action).toBe("allow") + expect(Permission.evaluate("task", "custom", plan!.permission).action).toBe("allow") + }), +) + +it.instance( + "user permission can allow the general subagent from plan mode", + () => + Effect.gen(function* () { + const plan = yield* load((svc) => svc.get("plan")) + expect(plan).toBeDefined() + expect(Permission.evaluate("task", "general", plan!.permission).action).toBe("allow") + }), + { + config: { + permission: { + task: { + general: "allow", + }, + }, + }, + }, +) + +it.instance("explore agent denies edit and write", () => + Effect.gen(function* () { + const explore = yield* load((svc) => svc.get("explore")) + expect(explore).toBeDefined() + expect(explore?.mode).toBe("subagent") + expect(evalPerm(explore, "edit")).toBe("deny") + expect(evalPerm(explore, "write")).toBe("deny") + expect(evalPerm(explore, "todowrite")).toBe("deny") + }), +) + +it.instance("explore agent asks for external directories and allows whitelisted external paths", () => + Effect.gen(function* () { + const explore = yield* load((svc) => svc.get("explore")) + expect(explore).toBeDefined() + expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") + expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action, + ).toBe("allow") + }), +) + +it.instance( + "reference config does not create subagents", + () => + Effect.gen(function* () { + const agents = yield* load((svc) => svc.list()) + const names = agents.map((agent) => agent.name) + expect(names).not.toContain("effect") + expect(names).not.toContain("effectFull") + expect(names).not.toContain("localdocs") + expect(names).not.toContain("localdocsFull") + }), + { + config: { + references: { + effect: "github.com/effect/effect-smol", + effectFull: { + repository: "Effect-TS/effect", + branch: "main", + }, + localdocs: "../docs", + localdocsFull: { + path: "../local-docs", + }, + }, + }, + }, +) + +it.instance("general agent denies todo tools", () => + Effect.gen(function* () { + const general = yield* load((svc) => svc.get("general")) + expect(general).toBeDefined() + expect(general?.mode).toBe("subagent") + expect(general?.hidden).toBeUndefined() + expect(evalPerm(general, "todowrite")).toBe("deny") + }), +) + +it.instance("compaction agent denies all permissions", () => + Effect.gen(function* () { + const compaction = yield* load((svc) => svc.get("compaction")) + expect(compaction).toBeDefined() + expect(compaction?.hidden).toBe(true) + expect(evalPerm(compaction, "bash")).toBe("deny") + expect(evalPerm(compaction, "edit")).toBe("deny") + expect(evalPerm(compaction, "read")).toBe("deny") + }), +) + +it.instance( + "custom agent from config creates new agent", + () => + Effect.gen(function* () { + const custom = yield* load((svc) => svc.get("my_custom_agent")) + expect(custom).toBeDefined() + expect(String(custom?.model?.providerID)).toBe("openai") + expect(String(custom?.model?.modelID)).toBe("gpt-4") + expect(custom?.description).toBe("My custom agent") + expect(custom?.temperature).toBe(0.5) + expect(custom?.topP).toBe(0.9) + expect(custom?.native).toBe(false) + expect(custom?.mode).toBe("all") + }), + { + config: { + agent: { + my_custom_agent: { + model: "openai/gpt-4", + description: "My custom agent", + temperature: 0.5, + top_p: 0.9, + }, + }, + }, + }, +) + +it.instance( + "custom agent config overrides native agent properties", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build).toBeDefined() + expect(String(build?.model?.providerID)).toBe("anthropic") + expect(String(build?.model?.modelID)).toBe("claude-3") + expect(build?.description).toBe("Custom build agent") + expect(build?.temperature).toBe(0.7) + expect(build?.color).toBe("#FF0000") + expect(build?.native).toBe(true) + }), + { + config: { + agent: { + build: { + model: "anthropic/claude-3", + description: "Custom build agent", + temperature: 0.7, + color: "#FF0000", + }, + }, + }, + }, +) + +it.instance( + "agent disable removes agent from list", + () => + Effect.gen(function* () { + const explore = yield* load((svc) => svc.get("explore")) + expect(explore).toBeUndefined() + const agents = yield* load((svc) => svc.list()) + const names = agents.map((a) => a.name) + expect(names).not.toContain("explore") + }), + { + config: { + agent: { + explore: { disable: true }, + }, + }, + }, +) + +it.instance( + "agent permission config merges with defaults", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build).toBeDefined() + // Specific pattern is denied + expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") + // Edit still allowed + expect(evalPerm(build, "edit")).toBe("allow") + }), + { + config: { + agent: { + build: { + permission: { + bash: { + "rm -rf *": "deny", + }, + }, + }, + }, + }, + }, +) + +it.instance( + "global permission config applies to all agents", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build).toBeDefined() + expect(evalPerm(build, "bash")).toBe("deny") + }), + { + config: { + permission: { + bash: "deny", + }, + }, + }, +) + +it.instance( + "agent steps/maxSteps config sets steps property", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + const plan = yield* load((svc) => svc.get("plan")) + expect(build?.steps).toBe(50) + expect(plan?.steps).toBe(100) + }), + { + config: { + agent: { + build: { steps: 50 }, + plan: { maxSteps: 100 }, + }, + }, + }, +) + +it.instance( + "agent mode can be overridden", + () => + Effect.gen(function* () { + const explore = yield* load((svc) => svc.get("explore")) + expect(explore?.mode).toBe("primary") + }), + { + config: { + agent: { + explore: { mode: "primary" }, + }, + }, + }, +) + +it.instance( + "agent name can be overridden", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build?.name).toBe("Builder") + }), + { + config: { + agent: { + build: { name: "Builder" }, + }, + }, + }, +) + +it.instance( + "agent prompt can be set from config", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build?.prompt).toBe("Custom system prompt") + }), + { + config: { + agent: { + build: { prompt: "Custom system prompt" }, + }, + }, + }, +) + +it.instance( + "unknown agent properties are placed into options", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build?.options.random_property).toBe("hello") + expect(build?.options.another_random).toBe(123) + }), + { + config: { + agent: { + build: { + random_property: "hello", + another_random: 123, + }, + }, + }, + }, +) + +it.instance( + "agent options merge correctly", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(build?.options.custom_option).toBe(true) + expect(build?.options.another_option).toBe("value") + }), + { + config: { + agent: { + build: { + options: { + custom_option: true, + another_option: "value", + }, + }, + }, + }, + }, +) + +it.instance( + "multiple custom agents can be defined", + () => + Effect.gen(function* () { + const agentA = yield* load((svc) => svc.get("agent_a")) + const agentB = yield* load((svc) => svc.get("agent_b")) + expect(agentA?.description).toBe("Agent A") + expect(agentA?.mode).toBe("subagent") + expect(agentB?.description).toBe("Agent B") + expect(agentB?.mode).toBe("primary") + }), + { + config: { + agent: { + agent_a: { + description: "Agent A", + mode: "subagent", + }, + agent_b: { + description: "Agent B", + mode: "primary", + }, + }, + }, + }, +) + +it.instance( + "Agent.list keeps the default agent first and sorts the rest by name", + () => + Effect.gen(function* () { + const names = (yield* load((svc) => svc.list())).map((a) => a.name) + expect(names[0]).toBe("plan") + expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b))) + }), + { + config: { + default_agent: "plan", + agent: { + zebra: { + description: "Zebra", + mode: "subagent", + }, + alpha: { + description: "Alpha", + mode: "subagent", + }, + }, + }, + }, +) + +it.instance("Agent.get returns undefined for non-existent agent", () => + Effect.gen(function* () { + const nonExistent = yield* load((svc) => svc.get("does_not_exist")) + expect(nonExistent).toBeUndefined() + }), +) + +it.instance("default permission includes doom_loop and external_directory as ask", () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(evalPerm(build, "doom_loop")).toBe("ask") + expect(evalPerm(build, "external_directory")).toBe("ask") + }), +) + +it.instance("webfetch is allowed by default", () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(evalPerm(build, "webfetch")).toBe("allow") + }), +) + +it.instance( + "legacy tools config converts to permissions", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(evalPerm(build, "bash")).toBe("deny") + expect(evalPerm(build, "read")).toBe("deny") + }), + { + config: { + agent: { + build: { + tools: { + bash: false, + read: false, + }, + }, + }, + }, + }, +) + +it.instance( + "legacy tools config maps write/edit/patch to edit permission", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(evalPerm(build, "edit")).toBe("deny") + }), + { + config: { + agent: { + build: { + tools: { + write: false, + }, + }, + }, + }, + }, +) + +it.instance( + "Truncate.GLOB is allowed even when user denies external_directory globally", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") + }), + { + config: { + permission: { + external_directory: "deny", + }, + }, + }, +) + +it.instance("global tmp directory children are allowed for external_directory", () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action, + ).toBe("allow") + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask") + }), +) + +it.instance( + "Truncate.GLOB is allowed even when user denies external_directory per-agent", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") + }), + { + config: { + agent: { + build: { + permission: { + external_directory: "deny", + }, + }, + }, + }, + }, +) + +it.instance( + "explicit Truncate.GLOB deny is respected", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") + expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") + }), + { + config: { + permission: { + external_directory: { + "*": "deny", + [Truncate.GLOB]: "deny", + }, + }, + }, + }, +) + +it.instance( + "skill directories are allowed for external_directory", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const skillDir = path.join(test.directory, ".opencode", "skill", "perm-skill") + yield* Effect.promise(() => + Bun.write( + path.join(skillDir, "SKILL.md"), + `--- +name: perm-skill +description: Permission skill. +--- + +# Permission Skill +`, + ), + ) + + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = test.directory + yield* Effect.addFinalizer(() => + Effect.sync(() => { + process.env.OPENCODE_TEST_HOME = home + }), + ) + + const build = yield* load((svc) => svc.get("build")) + const target = path.join(skillDir, "reference", "notes.md") + expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow") + }), + { git: true }, +) + +it.instance( + "project reference directories are allowed for external_directory", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const build = yield* load((svc) => svc.get("build")) + const target = path.resolve(test.directory, "../docs/reference/notes.md") + expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow") + }), + { + git: true, + config: { + references: { + docs: "../docs", + }, + }, + }, +) + +it.instance("defaultAgent returns build when no default_agent config", () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultAgent()) + expect(agent).toBe("build") + }), +) + +it.instance("defaultInfo returns resolved build agent when no default_agent config", () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultInfo()) + expect(agent.name).toBe("build") + expect(agent.mode).toBe("primary") + }), +) + +it.instance( + "defaultAgent respects default_agent config set to plan", + () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultAgent()) + expect(agent).toBe("plan") + }), + { + config: { + default_agent: "plan", + }, + }, +) + +it.instance( + "defaultAgent respects default_agent config set to custom agent with mode all", + () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultAgent()) + expect(agent).toBe("my_custom") + }), + { + config: { + default_agent: "my_custom", + agent: { + my_custom: { + description: "My custom agent", + }, + }, + }, + }, +) + +it.instance( + "defaultAgent throws when default_agent points to subagent", + () => expectDefaultAgentError('default agent "explore" is a subagent'), + { + config: { + default_agent: "explore", + }, + }, +) + +it.instance( + "defaultAgent throws when default_agent points to hidden agent", + () => expectDefaultAgentError('default agent "compaction" is hidden'), + { + config: { + default_agent: "compaction", + }, + }, +) + +it.instance( + "defaultAgent throws when default_agent points to non-existent agent", + () => expectDefaultAgentError('default agent "does_not_exist" not found'), + { + config: { + default_agent: "does_not_exist", + }, + }, +) + +it.instance( + "defaultAgent returns plan when build is disabled and default_agent not set", + () => + Effect.gen(function* () { + const agent = yield* load((svc) => svc.defaultAgent()) + // build is disabled, so it should return plan (next primary agent) + expect(agent).toBe("plan") + }), + { + config: { + agent: { + build: { disable: true }, + }, + }, + }, +) + +it.instance( + "defaultAgent throws when all primary agents are disabled", + () => expectDefaultAgentError("no primary visible agent found"), + { + config: { + agent: { + build: { disable: true }, + plan: { disable: true }, + }, + }, + }, +) diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6fc9197d9ae97e7e04dc600f49821bba83596383 --- /dev/null +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -0,0 +1,160 @@ +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { expect } from "bun:test" +import { Effect } from "effect" +import { Agent } from "../../src/agent/agent" +import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions" +import { Permission } from "../../src/permission" +import { testEffect } from "../lib/effect" + +const it = testEffect(LayerNode.compile(Agent.node)) + +function testAgent(input: { + name: string + mode: Agent.Info["mode"] + permission: Parameters[0] +}) { + return { + name: input.name, + mode: input.mode, + permission: Permission.fromConfig(input.permission), + options: {}, + } satisfies Agent.Info +} + +// `deriveSubagentSessionPermission` is imported from production. The test +// exercises the actual helper that task.ts uses to build the subagent's +// session permission, so any regression in that helper trips this test. + +it.instance("subagent permissions take precedence over parent agent restrictions", () => + Effect.gen(function* () { + const planAgent = yield* Agent.use.get("plan") + const generalAgent = yield* Agent.use.get("general") + + expect(planAgent).toBeDefined() + expect(generalAgent).toBeDefined() + // Sanity: the plan agent itself blocks edit. (Note: `write` and + // `apply_patch` route through the `edit` permission at the runtime + // tool layer — see Permission.disabled / EDIT_TOOLS.) + expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny") + + const parentSessionPermission: PermissionV1.Ruleset = [] + + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + subagent: generalAgent!, + }) + + // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639): + // ruleset: Permission.merge(agent.permission, session.permission ?? []) + const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission) + + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).not.toBe("deny") + expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set()) + }), +) + +it.instance("subagent's own read-only restriction remains effective", () => + Effect.gen(function* () { + const explore = yield* Agent.use.get("explore") + expect(explore).toBeDefined() + + const parentSessionPermission: PermissionV1.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + subagent: explore!, + }) + const effective = Permission.merge(explore!.permission, subagentSessionPermission) + + expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny") + }), +) + +it.instance( + "custom subagent can explicitly enable edits denied to its parent agent", + () => + Effect.gen(function* () { + const planAgent = yield* Agent.use.get("plan") + const my = yield* Agent.use.get("my_subagent") + expect(planAgent).toBeDefined() + expect(my).toBeDefined() + + const parentSessionPermission: PermissionV1.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + subagent: my!, + }) + const effective = Permission.merge(my!.permission, subagentSessionPermission) + + expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny") + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("allow") + expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set()) + }), + { + config: { + agent: { + my_subagent: { + description: "A user-defined subagent", + mode: "subagent", + permission: { + edit: "allow", + }, + }, + }, + }, + }, +) + +it.effect("subagent self permissions are preserved", () => + Effect.sync(() => { + const executor = testAgent({ + name: "executor", + mode: "subagent", + permission: { + "*": "deny", + read: "allow", + bash: "allow", + task: { + "*": "deny", + worker: "allow", + }, + edit: "allow", + }, + }) + + const effective = Permission.merge( + executor.permission, + deriveSubagentSessionPermission({ + parentSessionPermission: [], + subagent: executor, + }), + ) + + expect(Permission.evaluate("read", "README.md", effective).action).toBe("allow") + expect(Permission.evaluate("bash", "git status", effective).action).toBe("allow") + expect(Permission.evaluate("task", "worker", effective).action).toBe("allow") + expect(Permission.evaluate("task", "other", effective).action).toBe("deny") + expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(new Set()) + }), +) + +it.effect("subagent inherits parent session deny rules as hard runtime ceilings", () => + Effect.sync(() => { + const executor = testAgent({ + name: "executor", + mode: "subagent", + permission: { + bash: "allow", + }, + }) + const effective = Permission.merge( + executor.permission, + deriveSubagentSessionPermission({ + parentSessionPermission: Permission.fromConfig({ bash: "deny" }), + subagent: executor, + }), + ) + + expect(Permission.evaluate("bash", "git status", effective).action).toBe("deny") + }), +) diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0249aaeb847fa8c7210d47e5708bf82d3c3f96cd --- /dev/null +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -0,0 +1,51 @@ +import { expect } from "bun:test" +import { Npm } from "@opencode-ai/core/npm" +import { Effect } from "effect" +import path from "path" +import { pathToFileURL } from "url" +import { Agent } from "../../src/agent/agent" +import { Account } from "../../src/account/account" +import { Auth } from "../../src/auth" +import { RuntimeFlags } from "../../src/effect/runtime-flags" +import { Plugin } from "../../src/plugin" +import { Provider } from "../../src/provider/provider" +import { Skill } from "../../src/skill" +import { AccountTest } from "../fake/account" +import { AuthTest } from "../fake/auth" +import { NpmTest } from "../fake/npm" +import { ProviderTest } from "../fake/provider" +import { SkillTest } from "../fake/skill" +import { testEffect } from "../lib/effect" +import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +// `it.instance` skips InstanceBootstrap so LSP / MCP don't spin up — those +// services hang during scope teardown on Windows and aren't needed +// to verify plugin → config hook → Agent.list. +const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href + +const provider = ProviderTest.fake() +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Agent.node, Plugin.node]), [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [Provider.node, provider.layer], + [Skill.node, SkillTest.empty], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], + ]), +) + +it.instance( + "plugin-registered agents appear in Agent.list", + () => + Effect.gen(function* () { + yield* Plugin.Service.use((p) => p.init()) + const agents = yield* Agent.use.list() + const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name) + expect(added?.description).toBe(PLUGIN_AGENT.description) + expect(added?.mode).toBe(PLUGIN_AGENT.mode) + }), + { config: { plugin: [pluginUrl] } }, +) diff --git a/packages/opencode/test/cli/account.test.ts b/packages/opencode/test/cli/account.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfce3bffe61ed26fe35518df5203c40e9c2e6b8e --- /dev/null +++ b/packages/opencode/test/cli/account.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import stripAnsi from "strip-ansi" + +import { defaultConsoleUrl, formatAccountLabel, formatOrgLine } from "../../src/cli/cmd/account" + +describe("console account display", () => { + test("uses opencode.ai/console as the default login URL", () => { + expect(defaultConsoleUrl).toBe("https://opencode.ai/console") + }) + + test("includes the account url in account labels", () => { + expect(stripAnsi(formatAccountLabel({ email: "one@example.com", url: "https://one.example.com" }, false))).toBe( + "one@example.com https://one.example.com", + ) + }) + + test("includes the active marker in account labels", () => { + expect(stripAnsi(formatAccountLabel({ email: "one@example.com", url: "https://one.example.com" }, true))).toBe( + "one@example.com https://one.example.com (active)", + ) + }) + + test("includes the account url in org rows", () => { + expect( + stripAnsi( + formatOrgLine({ email: "one@example.com", url: "https://one.example.com" }, { id: "org-1", name: "One" }, true), + ), + ).toBe(" ● One one@example.com https://one.example.com org-1") + }) +}) diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1592b00146ec1e14aa39f9cc750c93b70a32fb78 --- /dev/null +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -0,0 +1,40 @@ +import { afterEach, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect } from "effect" +import { fileURLToPath } from "url" +import { InstanceRef } from "../../src/effect/instance-ref" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(LayerNode.compile(FSUtil.node)) + +afterEach(async () => { + await disposeAllInstances() +}) + +it.live("effect-cmd.ts does not restore legacy instance ALS", () => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url))) + expect(source).not.toContain("restore(ctx") + }), +) + +it.instance( + "InstanceRef remains the handler context across Effect promise awaits", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const ctx = yield* InstanceRef + if (!ctx) throw new Error("InstanceRef not provided") + + const directory = yield* Effect.promise(async () => { + await Promise.resolve() + return ctx.directory + }) + + expect(directory).toBe(test.directory) + }), + { git: true }, +) diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b29ca2b3bae1961bdc3ce6f069d7124174d51ca2 --- /dev/null +++ b/packages/opencode/test/cli/error.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test" +import { AccountTransportError } from "../../src/account/schema" +import { FormatError } from "../../src/cli/error" +import { UI } from "../../src/cli/ui" + +describe("cli.error", () => { + test("formats legacy and tagged config errors the same way", () => { + const cases = [ + { + tag: "ConfigJsonError", + data: { path: "/tmp/opencode.jsonc", message: "Unexpected token" }, + expected: "Config file at /tmp/opencode.jsonc is not valid JSON(C): Unexpected token", + }, + { + tag: "ConfigDirectoryTypoError", + data: { path: "/tmp/opencode.jsonc", dir: ".opencode", suggestion: "opencode" }, + expected: + 'Directory ".opencode" in /tmp/opencode.jsonc is not valid. Rename the directory to "opencode" or remove it. This is a common typo.', + }, + { + tag: "ConfigFrontmatterError", + data: { path: "/tmp/AGENTS.md", message: "failed frontmatter" }, + expected: "failed frontmatter", + }, + { + tag: "ConfigInvalidError", + data: { + path: "/tmp/opencode.jsonc", + message: "schema mismatch", + issues: [{ message: "Expected string", path: ["provider", "id"] }], + }, + expected: "Configuration is invalid at /tmp/opencode.jsonc: schema mismatch\n↳ Expected string provider.id", + }, + ] + + for (const item of cases) { + expect(FormatError({ name: item.tag, data: item.data })).toBe(item.expected) + expect(FormatError({ _tag: item.tag, ...item.data })).toBe(item.expected) + } + }) + + test("preserves multiline JSONC diagnostics for tagged config errors", () => { + const data = { + path: "/tmp/opencode.jsonc", + message: + '\n--- JSONC Input ---\n{\n "model": \n}\n--- Errors ---\nValueExpected at line 3, column 1\n Line 3: }\n ^\n--- End ---', + } + const expected = `Config file at ${data.path} is not valid JSON(C): ${data.message}` + + expect(FormatError({ name: "ConfigJsonError", data })).toBe(expected) + expect(FormatError({ _tag: "ConfigJsonError", ...data })).toBe(expected) + }) + + test("formats account transport errors clearly", () => { + const error = new AccountTransportError({ + method: "POST", + url: "https://console.opencode.ai/auth/device/code", + }) + + const formatted = FormatError(error) + + expect(formatted).toContain("Could not reach POST https://console.opencode.ai/auth/device/code.") + expect(formatted).toContain("This failed before the server returned an HTTP response.") + expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.") + }) + + test("formats legacy and tagged provider model errors the same way", () => { + const data = { + providerID: "anthropic", + modelID: "claude-sonet-4", + suggestions: ["claude-sonnet-4"], + } + const expected = [ + "Model not found: anthropic/claude-sonet-4", + "Did you mean: claude-sonnet-4", + "Try: `opencode models` to list available models", + "Or check your config (opencode.json) provider/model names", + ].join("\n") + + expect(FormatError({ name: "ProviderModelNotFoundError", data })).toBe(expected) + expect(FormatError({ _tag: "ProviderModelNotFoundError", ...data })).toBe(expected) + }) + + test("formats legacy and tagged provider init errors the same way", () => { + const data = { providerID: "anthropic" } + const expected = 'Failed to initialize provider "anthropic". Check credentials and configuration.' + + expect(FormatError({ name: "ProviderInitError", data })).toBe(expected) + expect(FormatError({ _tag: "ProviderInitError", ...data })).toBe(expected) + }) + + test("formats cancelled UI errors as empty output", () => { + expect(FormatError(new UI.CancelledError())).toBe("") + }) +}) diff --git a/packages/opencode/test/cli/github-action.test.ts b/packages/opencode/test/cli/github-action.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..57567d8c9bf491404aba4e8871fe2eaed21d72e2 --- /dev/null +++ b/packages/opencode/test/cli/github-action.test.ts @@ -0,0 +1,199 @@ +import { test, expect, describe } from "bun:test" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github" +import type { MessageV2 } from "../../src/session/message-v2" +import { SessionID, MessageID, PartID } from "../../src/session/schema" + +// Helper to create minimal valid parts +function createTextPart(text: string): SessionV1.Part { + return { + id: PartID.ascending(), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + type: "text" as const, + text, + } +} + +function createReasoningPart(text: string): SessionV1.Part { + return { + id: PartID.ascending(), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + type: "reasoning" as const, + text, + time: { start: 0 }, + } +} + +function createToolPart(tool: string, title: string, status: "completed" | "running" = "completed"): SessionV1.Part { + if (status === "completed") { + return { + id: PartID.ascending(), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + type: "tool" as const, + callID: "c1", + tool, + state: { + status: "completed", + input: {}, + output: "", + title, + metadata: {}, + time: { start: 0, end: 1 }, + }, + } + } + return { + id: PartID.ascending(), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + type: "tool" as const, + callID: "c1", + tool, + state: { + status: "running", + input: {}, + time: { start: 0 }, + }, + } +} + +function createStepStartPart(): SessionV1.Part { + return { + id: PartID.ascending(), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + type: "step-start" as const, + } +} + +function createStepFinishPart(): SessionV1.Part { + return { + id: PartID.ascending(), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + type: "step-finish" as const, + reason: "done", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } +} + +describe("extractResponseText", () => { + test("returns text from text part", () => { + const parts = [createTextPart("Hello world")] + expect(extractResponseText(parts)).toBe("Hello world") + }) + + test("returns last text part when multiple exist", () => { + const parts = [createTextPart("First"), createTextPart("Last")] + expect(extractResponseText(parts)).toBe("Last") + }) + + test("returns text even when tool parts follow", () => { + const parts = [createTextPart("I'll help with that."), createToolPart("todowrite", "3 todos")] + expect(extractResponseText(parts)).toBe("I'll help with that.") + }) + + test("returns null for reasoning-only response (signals summary needed)", () => { + const parts = [createReasoningPart("Let me think about this...")] + expect(extractResponseText(parts)).toBeNull() + }) + + test("returns null for tool-only response (signals summary needed)", () => { + // This is the exact scenario from the bug report - todowrite with no text + const parts = [createToolPart("todowrite", "8 todos")] + expect(extractResponseText(parts)).toBeNull() + }) + + test("returns null for multiple completed tools", () => { + const parts = [ + createToolPart("read", "src/file.ts"), + createToolPart("edit", "src/file.ts"), + createToolPart("bash", "bun test"), + ] + expect(extractResponseText(parts)).toBeNull() + }) + + test("returns null for running tool parts (signals summary needed)", () => { + const parts = [createToolPart("bash", "", "running")] + expect(extractResponseText(parts)).toBeNull() + }) + + test("throws on empty array", () => { + expect(() => extractResponseText([])).toThrow("no parts returned") + }) + + test("returns null for step-start only", () => { + const parts = [createStepStartPart()] + expect(extractResponseText(parts)).toBeNull() + }) + + test("returns null for step-finish only", () => { + const parts = [createStepFinishPart()] + expect(extractResponseText(parts)).toBeNull() + }) + + test("returns null for step-start and step-finish", () => { + const parts = [createStepStartPart(), createStepFinishPart()] + expect(extractResponseText(parts)).toBeNull() + }) + + test("returns text from multi-step response", () => { + const parts = [ + createStepStartPart(), + createToolPart("read", "src/file.ts"), + createTextPart("Done"), + createStepFinishPart(), + ] + expect(extractResponseText(parts)).toBe("Done") + }) + + test("prefers text over reasoning when both present", () => { + const parts = [createReasoningPart("Internal thinking..."), createTextPart("Final answer")] + expect(extractResponseText(parts)).toBe("Final answer") + }) + + test("prefers text over tools when both present", () => { + const parts = [createToolPart("read", "src/file.ts"), createTextPart("Here's what I found")] + expect(extractResponseText(parts)).toBe("Here's what I found") + }) +}) + +describe("formatPromptTooLargeError", () => { + test("formats error without files", () => { + const result = formatPromptTooLargeError([]) + expect(result).toBe("PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.") + }) + + test("formats error with files (base64 content)", () => { + // Base64 is ~33% larger than original, so we multiply by 0.75 to get original size + // 400 KB base64 = 300 KB original, 200 KB base64 = 150 KB original + const files = [ + { filename: "screenshot.png", content: "a".repeat(400 * 1024) }, + { filename: "diagram.png", content: "b".repeat(200 * 1024) }, + ] + const result = formatPromptTooLargeError(files) + + expect(result).toStartWith("PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.") + expect(result).toInclude("Files in prompt:") + expect(result).toInclude("screenshot.png (300 KB)") + expect(result).toInclude("diagram.png (150 KB)") + }) + + test("lists all files when multiple present", () => { + // Base64 sizes: 4KB -> 3KB, 8KB -> 6KB, 12KB -> 9KB + const files = [ + { filename: "img1.png", content: "x".repeat(4 * 1024) }, + { filename: "img2.jpg", content: "y".repeat(8 * 1024) }, + { filename: "img3.gif", content: "z".repeat(12 * 1024) }, + ] + const result = formatPromptTooLargeError(files) + + expect(result).toInclude("img1.png (3 KB)") + expect(result).toInclude("img2.jpg (6 KB)") + expect(result).toInclude("img3.gif (9 KB)") + }) +}) diff --git a/packages/opencode/test/cli/github-remote.test.ts b/packages/opencode/test/cli/github-remote.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed37b92d4106f175ae2f468d20ea845a1f62f862 --- /dev/null +++ b/packages/opencode/test/cli/github-remote.test.ts @@ -0,0 +1,90 @@ +import { test, expect } from "bun:test" +import { parseGitHubRemote } from "../../src/cli/cmd/github" + +test("parses https URL with .git suffix", () => { + expect(parseGitHubRemote("https://github.com/sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" }) +}) + +test("parses https URL without .git suffix", () => { + expect(parseGitHubRemote("https://github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" }) +}) + +test("parses git@ URL with .git suffix", () => { + expect(parseGitHubRemote("git@github.com:sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" }) +}) + +test("parses git@ URL without .git suffix", () => { + expect(parseGitHubRemote("git@github.com:sst/opencode")).toEqual({ owner: "sst", repo: "opencode" }) +}) + +test("parses ssh:// URL with .git suffix", () => { + expect(parseGitHubRemote("ssh://git@github.com/sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" }) +}) + +test("parses ssh:// URL without .git suffix", () => { + expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" }) +}) + +test("parses git protocol URLs from package metadata", () => { + expect(parseGitHubRemote("git://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) + expect(parseGitHubRemote("git+https://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) + expect(parseGitHubRemote("git+ssh://git@github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) +}) + +test("parses npm-style github shorthand", () => { + expect(parseGitHubRemote("github:facebook/react")).toBeNull() +}) + +test("parses http URL", () => { + expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" }) +}) + +test("parses URL with hyphenated owner and repo names", () => { + expect(parseGitHubRemote("https://github.com/my-org/my-repo.git")).toEqual({ owner: "my-org", repo: "my-repo" }) +}) + +test("parses URL with underscores in names", () => { + expect(parseGitHubRemote("git@github.com:my_org/my_repo.git")).toEqual({ owner: "my_org", repo: "my_repo" }) +}) + +test("parses URL with numbers in names", () => { + expect(parseGitHubRemote("https://github.com/org123/repo456")).toEqual({ owner: "org123", repo: "repo456" }) +}) + +test("parses repos with dots in the name", () => { + expect(parseGitHubRemote("https://github.com/socketio/socket.io.git")).toEqual({ + owner: "socketio", + repo: "socket.io", + }) + expect(parseGitHubRemote("https://github.com/vuejs/vue.js")).toEqual({ + owner: "vuejs", + repo: "vue.js", + }) + expect(parseGitHubRemote("git@github.com:mrdoob/three.js.git")).toEqual({ + owner: "mrdoob", + repo: "three.js", + }) + expect(parseGitHubRemote("https://github.com/jashkenas/backbone.git")).toEqual({ + owner: "jashkenas", + repo: "backbone", + }) +}) + +test("returns null for non-github URLs", () => { + expect(parseGitHubRemote("https://gitlab.com/owner/repo.git")).toBeNull() + expect(parseGitHubRemote("git@gitlab.com:owner/repo.git")).toBeNull() + expect(parseGitHubRemote("https://bitbucket.org/owner/repo")).toBeNull() +}) + +test("returns null for invalid URLs", () => { + expect(parseGitHubRemote("not-a-url")).toBeNull() + expect(parseGitHubRemote("")).toBeNull() + expect(parseGitHubRemote("github.com")).toBeNull() + expect(parseGitHubRemote("https://github.com/")).toBeNull() + expect(parseGitHubRemote("https://github.com/owner")).toBeNull() +}) + +test("returns null for URLs with extra path segments", () => { + expect(parseGitHubRemote("https://github.com/owner/repo/tree/main")).toBeNull() + expect(parseGitHubRemote("https://github.com/owner/repo/blob/main/file.ts")).toBeNull() +}) diff --git a/packages/opencode/test/cli/import.test.ts b/packages/opencode/test/cli/import.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..59824c3efcb988f880a01b6a959f0288501ceded --- /dev/null +++ b/packages/opencode/test/cli/import.test.ts @@ -0,0 +1,90 @@ +import { test, expect } from "bun:test" +import { + formatImportFileError, + parseShareUrl, + shouldAttachShareAuthHeaders, + transformShareData, + type ShareData, +} from "../../src/cli/cmd/import" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { PlatformError } from "effect" + +test("formats import file errors", () => { + expect( + formatImportFileError( + "test.json", + new PlatformError.PlatformError( + new PlatformError.SystemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + }), + ), + ), + ).toBe("File not found: test.json") + expect( + formatImportFileError( + "test.json", + new PlatformError.PlatformError( + new PlatformError.SystemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + }), + ), + ), + ).toBe("Failed to read file: Permission denied") + expect( + formatImportFileError( + "test.json", + new FSUtil.FileSystemError({ method: "readJson", cause: new SyntaxError("Unexpected token") }), + ), + ).toBe("Invalid JSON in test.json: Unexpected token") +}) + +// parseShareUrl tests +test("parses valid share URLs", () => { + expect(parseShareUrl("https://opncd.ai/share/Jsj3hNIW")).toBe("Jsj3hNIW") + expect(parseShareUrl("https://custom.example.com/share/abc123")).toBe("abc123") + expect(parseShareUrl("http://localhost:3000/share/test_id-123")).toBe("test_id-123") +}) + +test("rejects invalid URLs", () => { + expect(parseShareUrl("https://opncd.ai/s/Jsj3hNIW")).toBeNull() // legacy format + expect(parseShareUrl("https://opncd.ai/share/")).toBeNull() + expect(parseShareUrl("https://opncd.ai/share/id/extra")).toBeNull() + expect(parseShareUrl("not-a-url")).toBeNull() +}) + +test("only attaches share auth headers for same-origin URLs", () => { + expect(shouldAttachShareAuthHeaders("https://control.example.com/share/abc", "https://control.example.com")).toBe( + true, + ) + expect(shouldAttachShareAuthHeaders("https://other.example.com/share/abc", "https://control.example.com")).toBe(false) + expect(shouldAttachShareAuthHeaders("https://control.example.com:443/share/abc", "https://control.example.com")).toBe( + true, + ) + expect(shouldAttachShareAuthHeaders("not-a-url", "https://control.example.com")).toBe(false) +}) + +// transformShareData tests +test("transforms share data to storage format", () => { + const data: ShareData[] = [ + { type: "session", data: { id: "sess-1", title: "Test" } as any }, + { type: "message", data: { id: "msg-1", sessionID: "sess-1" } as any }, + { type: "part", data: { id: "part-1", messageID: "msg-1" } as any }, + { type: "part", data: { id: "part-2", messageID: "msg-1" } as any }, + ] + + const result = transformShareData(data)! + + expect(result.info.id).toBe("sess-1") + expect(result.messages).toHaveLength(1) + expect(result.messages[0].parts).toHaveLength(2) +}) + +test("returns null for invalid share data", () => { + expect(transformShareData([])).toBeNull() + expect(transformShareData([{ type: "message", data: {} as any }])).toBeNull() + expect(transformShareData([{ type: "session", data: { id: "s" } as any }])).toBeNull() // no messages +}) diff --git a/packages/opencode/test/cli/mcp-add.test.ts b/packages/opencode/test/cli/mcp-add.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a49384855a1528ed1b43419a2ca6c9a5a9f4c2c --- /dev/null +++ b/packages/opencode/test/cli/mcp-add.test.ts @@ -0,0 +1,74 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import path from "path" +import { cliIt } from "../lib/cli-process" + +describe("opencode mcp add (non-interactive subprocess)", () => { + cliIt.concurrent( + "adds a remote server with HTTP headers", + ({ home, opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn([ + "mcp", + "add", + "github", + "--url", + "https://example.com/mcp", + "--header", + "Authorization=Bearer {env:GITHUB_TOKEN}", + "--header", + "X-Option=one=two", + ]) + opencode.expectExit(result, 0) + + const config = yield* Effect.promise(() => + Bun.file(path.join(home, ".config", "opencode", "opencode.json")).json(), + ) + expect(config.mcp.github).toEqual({ + type: "remote", + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer {env:GITHUB_TOKEN}", + "X-Option": "one=two", + }, + }) + }), + 60_000, + ) + + cliIt.concurrent( + "adds a local server while preserving argv and environment values", + ({ home, opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn([ + "mcp", + "add", + "local", + "--env", + "API_KEY=secret", + "--env", + "VALUE=one=two", + "--", + "npx", + "-y", + "@example/server", + "--label", + "two words", + ]) + opencode.expectExit(result, 0) + + const config = yield* Effect.promise(() => + Bun.file(path.join(home, ".config", "opencode", "opencode.json")).json(), + ) + expect(config.mcp.local).toEqual({ + type: "local", + command: ["npx", "-y", "@example/server", "--label", "two words"], + environment: { + API_KEY: "secret", + VALUE: "one=two", + }, + }) + }), + 60_000, + ) +}) diff --git a/packages/opencode/test/cli/plugin-auth-picker.test.ts b/packages/opencode/test/cli/plugin-auth-picker.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a1cf059d5e6ee7e277ac5472cb9fdd5fdff2682 --- /dev/null +++ b/packages/opencode/test/cli/plugin-auth-picker.test.ts @@ -0,0 +1,120 @@ +import { test, expect, describe } from "bun:test" +import { resolvePluginProviders } from "../../src/cli/cmd/providers" +import type { Hooks } from "@opencode-ai/plugin" + +function hookWithAuth(provider: string): Hooks { + return { + auth: { + provider, + methods: [], + }, + } +} + +function hookWithoutAuth(): Hooks { + return {} +} + +describe("resolvePluginProviders", () => { + test("returns plugin providers not in models.dev", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(), + providerNames: {}, + }) + expect(result).toEqual([{ id: "portkey", name: "portkey" }]) + }) + + test("skips providers already in models.dev", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("anthropic")], + existingProviders: { anthropic: {} }, + disabled: new Set(), + providerNames: {}, + }) + expect(result).toEqual([]) + }) + + test("deduplicates across plugins", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey"), hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(), + providerNames: {}, + }) + expect(result).toEqual([{ id: "portkey", name: "portkey" }]) + }) + + test("respects disabled_providers", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(["portkey"]), + providerNames: {}, + }) + expect(result).toEqual([]) + }) + + test("respects enabled_providers when provider is absent", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(), + enabled: new Set(["anthropic"]), + providerNames: {}, + }) + expect(result).toEqual([]) + }) + + test("includes provider when in enabled set", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(), + enabled: new Set(["portkey"]), + providerNames: {}, + }) + expect(result).toEqual([{ id: "portkey", name: "portkey" }]) + }) + + test("resolves name from providerNames", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(), + providerNames: { portkey: "Portkey AI" }, + }) + expect(result).toEqual([{ id: "portkey", name: "Portkey AI" }]) + }) + + test("falls back to id when no name configured", () => { + const result = resolvePluginProviders({ + hooks: [hookWithAuth("portkey")], + existingProviders: {}, + disabled: new Set(), + providerNames: {}, + }) + expect(result).toEqual([{ id: "portkey", name: "portkey" }]) + }) + + test("skips hooks without auth", () => { + const result = resolvePluginProviders({ + hooks: [hookWithoutAuth(), hookWithAuth("portkey"), hookWithoutAuth()], + existingProviders: {}, + disabled: new Set(), + providerNames: {}, + }) + expect(result).toEqual([{ id: "portkey", name: "portkey" }]) + }) + + test("returns empty for no hooks", () => { + const result = resolvePluginProviders({ + hooks: [], + existingProviders: {}, + disabled: new Set(), + providerNames: {}, + }) + expect(result).toEqual([]) + }) +}) diff --git a/packages/opencode/test/effect/app-runtime-logger.test.ts b/packages/opencode/test/effect/app-runtime-logger.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0c34244f0d941b2bf0831bb6f2c5a20cea86c33 --- /dev/null +++ b/packages/opencode/test/effect/app-runtime-logger.test.ts @@ -0,0 +1,101 @@ +import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Context, Deferred, Effect, Fiber, Layer, Logger } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppLayer } from "../../src/effect/app-runtime" +import { EffectBridge } from "@/effect/bridge" +import { InstanceRef } from "../../src/effect/instance-ref" +import * as Observability from "@opencode-ai/core/observability" +import { attach } from "../../src/effect/run-service" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const observabilityLayer = LayerNode.compile(Observability.node) +const it = testEffect(LayerNode.compile(CrossSpawnSpawner.node)) + +function check(loggers: ReadonlySet>) { + return { + tracerLogger: loggers.has(Logger.tracerLogger), + size: loggers.size, + } +} + +it.live("makeRuntime installs the observability logger", () => + Effect.gen(function* () { + class Dummy extends Context.Service Effect.Effect> }>()( + "@test/Dummy", + ) {} + + const layer = Layer.effect( + Dummy, + Effect.gen(function* () { + return Dummy.of({ + current: () => Effect.map(Effect.service(Logger.CurrentLoggers), check), + }) + }), + ) + + const current = yield* Dummy.use((svc) => svc.current()).pipe( + Effect.provide(Layer.provideMerge(layer, observabilityLayer)), + ) + + expect(current.size).toBeGreaterThan(0) + }), +) + +it.live("AppLayer also installs the observability logger", () => + Effect.gen(function* () { + const current = yield* Effect.map(Effect.service(Logger.CurrentLoggers), check).pipe(Effect.provide(AppLayer)) + + expect(current.size).toBeGreaterThan(0) + }), +) + +it.instance( + "attach preserves InstanceRef from the current fiber context", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const current = yield* attach( + Effect.gen(function* () { + return (yield* InstanceRef)?.directory + }), + ) + + expect(current).toBe(test.directory) + }), + { git: true }, +) + +it.instance( + "EffectBridge preserves logger and instance context across async boundaries", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const bridge = yield* EffectBridge.make() + const started = yield* Deferred.make() + + const fiber = yield* Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + return yield* Effect.promise(() => + Promise.resolve().then(() => + bridge.promise( + Effect.gen(function* () { + return { + directory: (yield* InstanceRef)?.directory, + ...check(yield* Effect.service(Logger.CurrentLoggers)), + } + }), + ), + ), + ) + }).pipe(Effect.forkScoped) + + yield* Deferred.await(started) + const result = yield* Fiber.join(fiber) + + expect(result.directory).toBe(test.directory) + expect(result.size).toBeGreaterThan(0) + }).pipe(Effect.provide(observabilityLayer)), + { git: true }, +) diff --git a/packages/opencode/test/effect/config-service.test.ts b/packages/opencode/test/effect/config-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a1fc605eaac1e6967329e1b1cfbde883a08435d --- /dev/null +++ b/packages/opencode/test/effect/config-service.test.ts @@ -0,0 +1,65 @@ +import { describe, expect } from "bun:test" +import { Config, ConfigProvider, Context, Effect, Layer, Option } from "effect" +import { ConfigService } from "../../src/effect/config-service" +import { it } from "../lib/effect" + +class TestConfig extends ConfigService.Service()("@test/ConfigService", { + name: Config.string("NAME"), + token: Config.string("TOKEN").pipe(Config.option), + port: Config.number("PORT").pipe(Config.withDefault(3000)), +}) {} + +const fromConfig = (input: Record) => + TestConfig.layer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) + +const readConfig = TestConfig.useSync((config) => config) + +describe("ConfigService", () => { + it.effect("layer parses values from the active ConfigProvider", () => + Effect.gen(function* () { + const config = yield* readConfig.pipe( + Effect.provide( + fromConfig({ + NAME: "kit", + TOKEN: "secret", + PORT: "4096", + }), + ), + ) + + expect(config.name).toBe("kit") + expect(config.token).toEqual(Option.some("secret")) + expect(config.port).toBe(4096) + }), + ) + + it.effect("layer applies Effect Config defaults", () => + Effect.gen(function* () { + const config = yield* readConfig.pipe(Effect.provide(fromConfig({ NAME: "kit" }))) + + expect(config.name).toBe("kit") + expect(config.token).toEqual(Option.none()) + expect(config.port).toBe(3000) + }), + ) + + it.effect("layer provides an already parsed service value", () => + Effect.gen(function* () { + const config = yield* readConfig.pipe( + Effect.provide( + TestConfig.configLayer({ + name: "direct", + token: Option.some("parsed"), + port: 9000, + }), + ), + ) + + expect(config).toEqual({ + name: "direct", + token: Option.some("parsed"), + port: 9000, + } satisfies Context.Service.Shape) + }), + ) +}) diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..148eca8d5c697f1b34c2800e91cd1742f6153b78 --- /dev/null +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -0,0 +1,392 @@ +import { expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { $ } from "bun" +import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { + disposeAllInstancesEffect, + provideInstanceEffect, + reloadInstance, + testInstanceStoreLayer, + tmpdirScoped, +} from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(LayerNode.compile(CrossSpawnSpawner.node), testInstanceStoreLayer)) + +const access = (state: InstanceState.InstanceState, dir: string) => + InstanceState.get(state).pipe(provideInstanceEffect(dir)) + +const tmpdirGitScoped = Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => $`git commit --allow-empty --amend -m ${`root commit ${dir}`}`.cwd(dir).quiet()) + return dir +}) + +it.live("InstanceState caches values per directory", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + let n = 0 + const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n }))) + + const a = yield* access(state, dir) + const b = yield* access(state, dir) + + expect(a).toBe(b) + expect(n).toBe(1) + }), +) + +it.live("InstanceState isolates directories", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + let n = 0 + const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n }))) + + const a = yield* access(state, one) + const b = yield* access(state, two) + const c = yield* access(state, one) + + expect(a).toBe(c) + expect(a).not.toBe(b) + expect(n).toBe(2) + }), +) + +it.live("InstanceState invalidates on reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const seen: string[] = [] + let n = 0 + const state = yield* InstanceState.make(() => + Effect.acquireRelease( + Effect.sync(() => ({ n: ++n })), + (value) => + Effect.sync(() => { + seen.push(String(value.n)) + }), + ), + ) + + const a = yield* access(state, dir) + yield* reloadInstance({ directory: dir }) + const b = yield* access(state, dir) + + expect(a).not.toBe(b) + expect(seen).toEqual(["1"]) + }), +) + +it.live("InstanceState invalidates on disposeAll", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + const seen: string[] = [] + const state = yield* InstanceState.make((ctx) => + Effect.acquireRelease( + Effect.sync(() => ({ dir: ctx.directory })), + (value) => + Effect.sync(() => { + seen.push(value.dir) + }), + ), + ) + + yield* access(state, one) + yield* access(state, two) + yield* disposeAllInstancesEffect + + expect(seen.sort()).toEqual([one, two].sort()) + }), +) + +it.live("InstanceState.get reads the current directory lazily", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + + interface Api { + readonly get: () => Effect.Effect + } + + class Test extends Context.Service()("@test/InstanceStateLazy") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + const get = InstanceState.get(state) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + return yield* get + }), + }) + }), + ) + } + + yield* Effect.gen(function* () { + const a = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one)) + const b = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(two)) + + expect(a).toBe(one) + expect(b).toBe(two) + }).pipe(Effect.provide(Test.layer)) + }), +) + +it.live("InstanceState preserves directory across async boundaries", () => + Effect.gen(function* () { + const one = yield* tmpdirGitScoped + const two = yield* tmpdirGitScoped + const three = yield* tmpdirGitScoped + + interface Api { + readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }> + } + + class Test extends Context.Service()("@test/InstanceStateAsync") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => + Effect.sync(() => ({ + directory: ctx.directory, + worktree: ctx.worktree, + project: ctx.project.id, + })), + ) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + yield* Effect.sleep(Duration.millis(1)) + yield* Effect.sleep(Duration.millis(1)) + for (let i = 0; i < 100; i++) { + yield* Effect.yieldNow + } + for (let i = 0; i < 100; i++) { + yield* Effect.promise(() => Promise.resolve()) + } + yield* Effect.sleep(Duration.millis(2)) + yield* Effect.sleep(Duration.millis(1)) + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + yield* Effect.gen(function* () { + const [a, b, c] = yield* Effect.all( + [one, two, three].map((dir) => Test.use((svc) => svc.get()).pipe(provideInstanceEffect(dir))), + { concurrency: "unbounded" }, + ) + + expect(a).toEqual({ directory: one, worktree: one, project: a.project }) + expect(b).toEqual({ directory: two, worktree: two, project: b.project }) + expect(c).toEqual({ directory: three, worktree: three, project: c.project }) + expect(a.project).not.toBe(b.project) + expect(a.project).not.toBe(c.project) + expect(b.project).not.toBe(c.project) + }).pipe(Effect.provide(Test.layer)) + }), +) + +it.live("InstanceState survives high-contention concurrent access", () => + Effect.gen(function* () { + const dirs = yield* Effect.all( + Array.from({ length: 20 }, () => tmpdirScoped()), + { concurrency: "unbounded" }, + ) + + interface Api { + readonly get: () => Effect.Effect + } + + class Test extends Context.Service()("@test/HighContention") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + for (let i = 0; i < 10; i++) { + yield* Effect.sleep(Duration.millis(Math.random() * 3)) + yield* Effect.yieldNow + yield* Effect.promise(() => Promise.resolve()) + } + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + yield* Effect.gen(function* () { + const results = yield* Effect.all( + dirs.map((dir) => Test.use((svc) => svc.get()).pipe(provideInstanceEffect(dir))), + { concurrency: "unbounded" }, + ) + + expect(results).toEqual(dirs) + }).pipe(Effect.provide(Test.layer)) + }), +) + +it.live("InstanceState correct after interleaved init and dispose", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + + interface Api { + readonly get: () => Effect.Effect + } + + class Test extends Context.Service()("@test/InterleavedDispose") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => + Effect.gen(function* () { + yield* Effect.sleep(Duration.millis(5)) + return ctx.directory + }), + ) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + yield* Effect.gen(function* () { + const a = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one)) + expect(a).toBe(one) + + const [, b] = yield* Effect.all( + [reloadInstance({ directory: one }), Test.use((svc) => svc.get()).pipe(provideInstanceEffect(two))], + { concurrency: "unbounded" }, + ) + expect(b).toBe(two) + + const c = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one)) + expect(c).toBe(one) + }).pipe(Effect.provide(Test.layer)) + }), +) + +it.live("InstanceState mutation in one directory does not leak to another", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 }))) + + const s1 = yield* access(state, one) + s1.count = 42 + + const s2 = yield* access(state, two) + expect(s2.count).toBe(0) + + const s1again = yield* access(state, one) + expect(s1again.count).toBe(42) + expect(s1again).toBe(s1) + }), +) + +it.live("InstanceState dedupes concurrent lookups", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + let n = 0 + const state = yield* InstanceState.make(() => + Effect.gen(function* () { + n += 1 + yield* Effect.sleep(Duration.millis(10)) + return { n } + }), + ) + + const [a, b] = yield* Effect.all([access(state, dir), access(state, dir)], { concurrency: "unbounded" }) + expect(a).toBe(b) + expect(n).toBe(1) + }), +) + +it.live("InstanceState survives deferred resume from the same instance context", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + + interface Api { + readonly get: (gate: Deferred.Deferred) => Effect.Effect + } + + class Test extends Context.Service()("@test/DeferredResume") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + + return Test.of({ + get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred) { + yield* Deferred.await(gate) + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + yield* Effect.gen(function* () { + const gate = yield* Deferred.make() + const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstanceEffect(dir), Effect.forkScoped) + + yield* Deferred.succeed(gate, undefined).pipe(provideInstanceEffect(dir)) + const exit = yield* Fiber.await(fiber) + + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir) + }).pipe(Effect.provide(Test.layer)) + }), +) + +it.live("InstanceState survives deferred resume outside ALS when InstanceRef is set", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + + interface Api { + readonly get: (gate: Deferred.Deferred) => Effect.Effect + } + + class Test extends Context.Service()("@test/DeferredResumeOutside") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + + return Test.of({ + get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred) { + yield* Deferred.await(gate) + return yield* InstanceState.get(state) + }), + }) + }), + ) + } + + yield* Effect.gen(function* () { + const gate = yield* Deferred.make() + const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstanceEffect(dir), Effect.forkScoped) + + yield* Deferred.succeed(gate, undefined) + const exit = yield* Fiber.await(fiber) + + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir) + }).pipe(Effect.provide(Test.layer)) + }), +) diff --git a/packages/opencode/test/effect/run-service.test.ts b/packages/opencode/test/effect/run-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..08c8fef436519b2fccb039c3041f1812733497ea --- /dev/null +++ b/packages/opencode/test/effect/run-service.test.ts @@ -0,0 +1,89 @@ +import { expect } from "bun:test" +import { Effect, Layer, Context } from "effect" +import { InstanceRef } from "../../src/effect/instance-ref" +import { makeRuntime } from "../../src/effect/run-service" +import { ProjectV2 } from "@opencode-ai/core/project" +import { it } from "../lib/effect" + +class Shared extends Context.Service()("@test/Shared") {} +const testDirectory = "/tmp/opencode-test" + +it.live("makeRuntime shares dependent layers through the shared memo map", () => + Effect.gen(function* () { + let n = 0 + + const shared = Layer.effect( + Shared, + Effect.sync(() => { + n += 1 + return Shared.of({ id: n }) + }), + ) + + class One extends Context.Service Effect.Effect }>()("@test/One") {} + const one = Layer.effect( + One, + Effect.gen(function* () { + const svc = yield* Shared + return One.of({ + get: Effect.fn("One.get")(() => Effect.succeed(svc.id)), + }) + }), + ).pipe(Layer.provide(shared)) + + class Two extends Context.Service Effect.Effect }>()("@test/Two") {} + const two = Layer.effect( + Two, + Effect.gen(function* () { + const svc = yield* Shared + return Two.of({ + get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)), + }) + }), + ).pipe(Layer.provide(shared)) + + const { runPromise: runOne } = makeRuntime(One, one) + const { runPromise: runTwo } = makeRuntime(Two, two) + + expect(yield* Effect.promise(() => runOne((svc) => svc.get()))).toBe(1) + expect(yield* Effect.promise(() => runTwo((svc) => svc.get()))).toBe(1) + expect(n).toBe(1) + }), +) + +it.live("makeRuntime inherits InstanceRef from the current fiber", () => + Effect.gen(function* () { + class NeedsInstance extends Context.Service< + NeedsInstance, + { readonly directory: () => Effect.Effect } + >()("@test/NeedsInstance") {} + + const runtime = makeRuntime( + NeedsInstance, + Layer.succeed( + NeedsInstance, + NeedsInstance.of({ + directory: () => + Effect.gen(function* () { + return (yield* InstanceRef)?.directory + }), + }), + ), + ) + + const actual = yield* Effect.promise(() => runtime.runPromise((svc) => svc.directory())) + + expect(actual).toBe(testDirectory) + }).pipe( + Effect.provideService(InstanceRef, { + directory: testDirectory, + worktree: testDirectory, + project: { + id: ProjectV2.ID.global, + worktree: testDirectory, + time: { created: 0, updated: 0 }, + sandboxes: [], + }, + }), + ), +) diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..27fe9e02542ecdc846e59113eec71a397197cdd8 --- /dev/null +++ b/packages/opencode/test/effect/runner.test.ts @@ -0,0 +1,514 @@ +import { describe, expect } from "bun:test" +import { Cause, Deferred, Effect, Exit, Fiber, Latch, Ref, Scope } from "effect" +import { Runner } from "@/effect/runner" +import { it } from "../lib/effect" + +const waitForState = (runner: Runner.Runner, tag: Runner.State["_tag"]) => + Effect.gen(function* () { + while (runner.state._tag !== tag) yield* Effect.yieldNow + }).pipe(Effect.timeout("1 second")) + +describe("Runner", () => { + // --- ensureRunning semantics --- + + it.live( + "ensureRunning starts work and returns result", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const result = yield* runner.ensureRunning(Effect.succeed("hello")) + expect(result).toBe("hello") + expect(runner.state._tag).toBe("Idle") + expect(runner.busy).toBe(false) + }), + ) + + it.live( + "ensureRunning propagates work failures", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const exit = yield* runner.ensureRunning(Effect.fail("boom")).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + expect(runner.state._tag).toBe("Idle") + }), + ) + + it.live( + "concurrent callers share the same run", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const calls = yield* Ref.make(0) + const work = Effect.gen(function* () { + yield* Ref.update(calls, (n) => n + 1) + yield* Effect.sleep("10 millis") + return "shared" + }) + + const [a, b] = yield* Effect.all([runner.ensureRunning(work), runner.ensureRunning(work)], { + concurrency: "unbounded", + }) + + expect(a).toBe("shared") + expect(b).toBe("shared") + expect(yield* Ref.get(calls)).toBe(1) + }), + ) + + it.live( + "concurrent callers all receive same error", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const work = Effect.gen(function* () { + yield* Effect.sleep("10 millis") + return yield* Effect.fail("boom") + }) + + const [a, b] = yield* Effect.all( + [runner.ensureRunning(work).pipe(Effect.exit), runner.ensureRunning(work).pipe(Effect.exit)], + { concurrency: "unbounded" }, + ) + + expect(Exit.isFailure(a)).toBe(true) + expect(Exit.isFailure(b)).toBe(true) + }), + ) + + it.live( + "ensureRunning can be called again after previous run completes", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + expect(yield* runner.ensureRunning(Effect.succeed("first"))).toBe("first") + expect(yield* runner.ensureRunning(Effect.succeed("second"))).toBe("second") + }), + ) + + it.live( + "second ensureRunning ignores new work if already running", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const ran = yield* Ref.make([]) + + const first = Effect.gen(function* () { + yield* Ref.update(ran, (a) => [...a, "first"]) + yield* Effect.sleep("50 millis") + return "first-result" + }) + const second = Effect.gen(function* () { + yield* Ref.update(ran, (a) => [...a, "second"]) + return "second-result" + }) + + const [a, b] = yield* Effect.all([runner.ensureRunning(first), runner.ensureRunning(second)], { + concurrency: "unbounded", + }) + + expect(a).toBe("first-result") + expect(b).toBe("first-result") + expect(yield* Ref.get(ran)).toEqual(["first"]) + }), + ) + + // --- cancel semantics --- + + it.live( + "cancel interrupts running work", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const started = yield* Deferred.make() + const fiber = yield* runner + .ensureRunning( + Effect.gen(function* () { + yield* Deferred.succeed(started, void 0) + return yield* Effect.never.pipe(Effect.as("never")) + }), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(started) + expect(runner.busy).toBe(true) + expect(runner.state._tag).toBe("Running") + + yield* runner.cancel + expect(runner.busy).toBe(false) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + + it.live( + "cancel on idle is a no-op", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + yield* runner.cancel + expect(runner.busy).toBe(false) + }), + ) + + it.live( + "cancel with onInterrupt resolves callers gracefully", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s, { onInterrupt: Effect.succeed("fallback") }) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) + yield* waitForState(runner, "Running") + + yield* runner.cancel + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toBe("fallback") + }), + ) + + it.live( + "cancel with queued callers resolves all", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s, { onInterrupt: Effect.succeed("fallback") }) + + const a = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) + yield* waitForState(runner, "Running") + const b = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) + yield* Effect.yieldNow + + yield* runner.cancel + + const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(exitA)).toBe(true) + expect(Exit.isSuccess(exitB)).toBe(true) + if (Exit.isSuccess(exitA)) expect(exitA.value).toBe("fallback") + if (Exit.isSuccess(exitB)) expect(exitB.value).toBe("fallback") + }), + ) + + it.live( + "work can be started after cancel", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) + yield* waitForState(runner, "Running") + yield* runner.cancel + yield* Fiber.await(fiber) + + const result = yield* runner.ensureRunning(Effect.succeed("after-cancel")) + expect(result).toBe("after-cancel") + }), + ) + + it.live( + "cancel does not deadlock when replacement work starts before interrupted run exits", + Effect.gen(function* () { + const s = yield* Scope.Scope + const hit = yield* Deferred.make() + const hold = yield* Deferred.make() + const done = yield* Deferred.make() + + yield* Effect.gen(function* () { + const runner = Runner.make(s) + const first = Effect.never.pipe( + Effect.onInterrupt(() => Deferred.succeed(hit, undefined)), + Effect.ensuring(Deferred.await(hold)), + Effect.as("first"), + ) + + const a = yield* runner.ensureRunning(first).pipe(Effect.exit, Effect.forkChild) + yield* waitForState(runner, "Running") + + const stop = yield* runner.cancel.pipe(Effect.forkChild) + yield* Deferred.await(hit).pipe(Effect.timeout("250 millis")) + + const b = yield* runner.ensureRunning(Deferred.await(done).pipe(Effect.as("second"))).pipe(Effect.forkChild) + yield* Effect.yieldNow + expect(runner.busy).toBe(true) + + yield* Deferred.succeed(hold, undefined) + const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis")) + expect(Exit.isSuccess(stopExit)).toBe(true) + + expect(runner.busy).toBe(true) + yield* Deferred.succeed(done, undefined) + expect(yield* Fiber.join(b).pipe(Effect.timeout("250 millis"))).toBe("second") + expect(runner.busy).toBe(false) + + const exit = yield* Fiber.join(a) + expect(Exit.isFailure(exit)).toBe(true) + }).pipe( + Effect.ensuring( + Effect.all([Deferred.succeed(hold, undefined), Deferred.succeed(done, undefined)], { discard: true }).pipe( + Effect.ignore, + ), + ), + ) + }), + ) + + // --- shell semantics --- + + it.live( + "shell runs exclusively", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const result = yield* runner.startShell(Effect.succeed("shell-done")) + expect(result).toBe("shell-done") + expect(runner.busy).toBe(false) + }), + ) + + it.live( + "shell rejects when run is active", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const started = yield* Deferred.make() + const fiber = yield* runner + .ensureRunning( + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + return yield* Effect.never.pipe(Effect.as("x")) + }), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(started).pipe(Effect.timeout("250 millis")) + yield* Effect.gen(function* () { + while (runner.state._tag !== "Running") yield* Effect.yieldNow + }).pipe(Effect.timeout("250 millis")) + + const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + + yield* runner.cancel + yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) + }), + ) + + it.live( + "shell rejects when another shell is running", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const gate = yield* Deferred.make() + + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("first"))).pipe(Effect.forkChild) + yield* waitForState(runner, "Shell") + + const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Runner.Busy) + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(sh) + }), + ) + + it.live( + "cancel interrupts shell", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const gate = yield* Deferred.make() + + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ignored"))).pipe(Effect.forkChild) + yield* waitForState(runner, "Shell") + + const stop = yield* runner.cancel.pipe(Effect.forkChild) + const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis")) + expect(Exit.isSuccess(stopExit)).toBe(true) + expect(runner.busy).toBe(false) + + const shellExit = yield* Fiber.await(sh) + expect(Exit.isFailure(shellExit)).toBe(true) + + yield* Deferred.succeed(gate, undefined).pipe(Effect.ignore) + }), + ) + + it.live( + "cancel does not mask shell defects", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s, { onInterrupt: Effect.succeed("interrupted") }) + const ready = yield* Latch.make() + + const sh = yield* runner + .startShell( + Effect.gen(function* () { + yield* ready.open + return yield* Effect.never.pipe(Effect.as("ignored")) + }).pipe(Effect.ensuring(Effect.die("boom"))), + ready, + ) + .pipe(Effect.forkChild) + yield* ready.await.pipe(Effect.timeout("250 millis")) + + yield* runner.cancel + expect(Exit.isFailure(yield* Fiber.await(sh))).toBe(true) + }), + ) + + // --- shell→run handoff --- + + it.live( + "ensureRunning queues behind shell then runs after", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const gate = yield* Deferred.make() + + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell-result"))).pipe(Effect.forkChild) + yield* waitForState(runner, "Shell") + expect(runner.state._tag).toBe("Shell") + + const run = yield* runner.ensureRunning(Effect.succeed("run-result")).pipe(Effect.forkChild) + yield* waitForState(runner, "ShellThenRun") + expect(runner.state._tag).toBe("ShellThenRun") + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(sh) + + const exit = yield* Fiber.await(run) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toBe("run-result") + expect(runner.state._tag).toBe("Idle") + }), + ) + + it.live( + "multiple ensureRunning callers share the queued run behind shell", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const calls = yield* Ref.make(0) + const gate = yield* Deferred.make() + + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell"))).pipe(Effect.forkChild) + yield* waitForState(runner, "Shell") + + const work = Effect.gen(function* () { + yield* Ref.update(calls, (n) => n + 1) + return "run" + }) + const a = yield* runner.ensureRunning(work).pipe(Effect.forkChild) + const b = yield* runner.ensureRunning(work).pipe(Effect.forkChild) + yield* waitForState(runner, "ShellThenRun") + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(sh) + + const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(exitA)).toBe(true) + expect(Exit.isSuccess(exitB)).toBe(true) + expect(yield* Ref.get(calls)).toBe(1) + }), + ) + + it.live( + "cancel during shell_then_run cancels both", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + + const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) + yield* waitForState(runner, "Shell") + + const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) + yield* waitForState(runner, "ShellThenRun") + expect(runner.state._tag).toBe("ShellThenRun") + + yield* runner.cancel + expect(runner.busy).toBe(false) + + yield* Fiber.await(sh) + const exit = yield* Fiber.await(run) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + + // --- lifecycle callbacks --- + + it.live( + "onIdle fires when returning to idle from running", + Effect.gen(function* () { + const s = yield* Scope.Scope + const count = yield* Ref.make(0) + const runner = Runner.make(s, { + onIdle: Ref.update(count, (n) => n + 1), + }) + yield* runner.ensureRunning(Effect.succeed("ok")) + expect(yield* Ref.get(count)).toBe(1) + }), + ) + + it.live( + "onIdle fires on cancel", + Effect.gen(function* () { + const s = yield* Scope.Scope + const count = yield* Ref.make(0) + const runner = Runner.make(s, { + onIdle: Ref.update(count, (n) => n + 1), + }) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) + yield* waitForState(runner, "Running") + yield* runner.cancel + yield* Fiber.await(fiber) + expect(yield* Ref.get(count)).toBeGreaterThanOrEqual(1) + }), + ) + + it.live( + "onBusy fires when shell starts", + Effect.gen(function* () { + const s = yield* Scope.Scope + const count = yield* Ref.make(0) + const runner = Runner.make(s, { + onBusy: Ref.update(count, (n) => n + 1), + }) + yield* runner.startShell(Effect.succeed("done")) + expect(yield* Ref.get(count)).toBe(1) + }), + ) + + // --- busy flag --- + + it.live( + "busy is true during run", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const gate = yield* Deferred.make() + + const fiber = yield* runner.ensureRunning(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild) + yield* waitForState(runner, "Running") + expect(runner.busy).toBe(true) + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(fiber) + expect(runner.busy).toBe(false) + }), + ) + + it.live( + "busy is true during shell", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const gate = yield* Deferred.make() + + const fiber = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild) + yield* waitForState(runner, "Shell") + expect(runner.busy).toBe(true) + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(fiber) + expect(runner.busy).toBe(false) + }), + ) +}) diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca5ca1d7b0ddd1b8a6beb5a072fd8bc57b35774a --- /dev/null +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -0,0 +1,374 @@ +import { describe, expect } from "bun:test" +import { ConfigProvider, Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { RuntimeFlags } from "../../src/effect/runtime-flags" +import { it } from "../lib/effect" + +const fromConfig = (input: Record) => + AppNodeBuilder.build(RuntimeFlags.node).pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) + +const readFlags = RuntimeFlags.Service.useSync((flags) => flags) + +describe("RuntimeFlags", () => { + it.effect("layer defaults autoShare to false", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) + + expect(flags.autoShare).toBe(false) + }), + ) + + it.effect("layer parses plugin flags from the active ConfigProvider", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe( + Effect.provide( + fromConfig({ + OPENCODE_PURE: "true", + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", + OPENCODE_AUTO_SHARE: "true", + OPENCODE_DISABLE_EMBEDDED_WEB_UI: "true", + OPENCODE_DISABLE_EXTERNAL_SKILLS: "true", + OPENCODE_DISABLE_LSP_DOWNLOAD: "true", + OPENCODE_EXPERIMENTAL: "true", + OPENCODE_ENABLE_EXA: "true", + OPENCODE_ENABLE_PARALLEL: "true", + OPENCODE_ENABLE_EXPERIMENTAL_MODELS: "true", + OPENCODE_ENABLE_QUESTION_TOOL: "true", + OPENCODE_CLIENT: "desktop", + }), + ), + ) + + expect(flags.pure).toBe(true) + expect(flags.autoShare).toBe(true) + expect(flags.disableDefaultPlugins).toBe(true) + expect(flags.disableEmbeddedWebUi).toBe(true) + expect(flags.disableExternalSkills).toBe(true) + expect(flags.disableLspDownload).toBe(true) + expect(flags.disableClaudeCodePrompt).toBe(false) + expect(flags.enableExa).toBe(true) + expect(flags.enableParallel).toBe(true) + expect(flags.enableExperimentalModels).toBe(true) + expect(flags.enableQuestionTool).toBe(true) + expect(flags.experimentalReferences).toBe(true) + expect(flags.experimentalBackgroundSubagents).toBe(true) + expect(flags.experimentalLspTy).toBe(false) + expect(flags.experimentalLspTool).toBe(true) + expect(flags.experimentalOxfmt).toBe(true) + expect(flags.experimentalPlanMode).toBe(true) + expect(flags.experimentalEventSystem).toBe(true) + expect(flags.experimentalWorkspaces).toBe(true) + expect(flags.experimentalIconDiscovery).toBe(true) + expect(flags.experimentalNativeLlm).toBe(false) + expect(flags.experimentalWebSockets).toBe(false) + expect(flags.client).toBe("desktop") + }), + ) + + it.effect("layer parses OPENCODE_EXPERIMENTAL_LSP_TY", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe( + Effect.provide( + fromConfig({ + OPENCODE_EXPERIMENTAL_LSP_TY: "true", + }), + ), + ) + + expect(flags.experimentalLspTy).toBe(true) + }), + ) + + it.effect("enables native LLM via dedicated flag only", () => + Effect.gen(function* () { + const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_NATIVE_LLM: "true" }))) + const umbrella = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" }))) + + expect(explicit.experimentalNativeLlm).toBe(true) + expect(umbrella.experimentalNativeLlm).toBe(false) + }), + ) + + it.effect("enables WebSockets via dedicated flag only", () => + Effect.gen(function* () { + const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_WEBSOCKETS: "true" }))) + const umbrella = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" }))) + + expect(explicit.experimentalWebSockets).toBe(true) + expect(umbrella.experimentalWebSockets).toBe(false) + }), + ) + + it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe( + Effect.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, bashDefaultTimeoutMs: 1_000 })), + ) + + expect(flags.pure).toBe(false) + expect(flags.autoShare).toBe(false) + expect(flags.disableDefaultPlugins).toBe(true) + expect(flags.disableEmbeddedWebUi).toBe(false) + expect(flags.disableExternalSkills).toBe(false) + expect(flags.disableLspDownload).toBe(false) + expect(flags.disableClaudeCodePrompt).toBe(false) + expect(flags.disableClaudeCodeSkills).toBe(false) + expect(flags.enableExa).toBe(false) + expect(flags.experimentalIconDiscovery).toBe(false) + expect(flags.experimentalOxfmt).toBe(false) + expect(flags.outputTokenMax).toBeUndefined() + expect(flags.bashDefaultTimeoutMs).toBe(1_000) + expect(flags.enableExperimentalModels).toBe(false) + expect(flags.client).toBe("cli") + }), + ) + + it.effect("experimentalIconDiscovery defaults to false", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) + + expect(flags.experimentalIconDiscovery).toBe(false) + }), + ) + + it.effect("disableExternalSkills defaults to false", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) + + expect(flags.disableExternalSkills).toBe(false) + }), + ) + + it.effect("disableExternalSkills reads OPENCODE_DISABLE_EXTERNAL_SKILLS", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_EXTERNAL_SKILLS: "true" }))) + + expect(flags.disableExternalSkills).toBe(true) + }), + ) + + it.effect("disableLspDownload defaults to false", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) + + expect(flags.disableLspDownload).toBe(false) + }), + ) + + it.effect("disableLspDownload reads OPENCODE_DISABLE_LSP_DOWNLOAD", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_LSP_DOWNLOAD: "true" }))) + + expect(flags.disableLspDownload).toBe(true) + }), + ) + + it.effect("disableClaudeCodePrompt defaults to false", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) + + expect(flags.disableClaudeCodePrompt).toBe(false) + }), + ) + + it.effect("disableClaudeCodePrompt reads OPENCODE_DISABLE_CLAUDE_CODE_PROMPT", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: "true" }))) + + expect(flags.disableClaudeCodePrompt).toBe(true) + }), + ) + + it.effect("disableClaudeCodePrompt inherits OPENCODE_DISABLE_CLAUDE_CODE", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_CLAUDE_CODE: "true" }))) + + expect(flags.disableClaudeCodePrompt).toBe(true) + }), + ) + + it.effect("experimentalIconDiscovery reads OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true" }))) + + expect(flags.experimentalIconDiscovery).toBe(true) + }), + ) + + it.effect("experimentalIconDiscovery inherits OPENCODE_EXPERIMENTAL", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" }))) + + expect(flags.experimentalIconDiscovery).toBe(true) + }), + ) + + it.effect("specific experimental flags override OPENCODE_EXPERIMENTAL", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe( + Effect.provide( + fromConfig({ + OPENCODE_EXPERIMENTAL: "true", + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "false", + }), + ), + ) + + expect(flags.experimentalIconDiscovery).toBe(false) + }), + ) + + it.effect("experimentalOxfmt defaults to false", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) + + expect(flags.experimentalOxfmt).toBe(false) + }), + ) + + it.effect("experimentalOxfmt is enabled by OPENCODE_EXPERIMENTAL_OXFMT", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe( + Effect.provide( + fromConfig({ + OPENCODE_EXPERIMENTAL_OXFMT: "true", + }), + ), + ) + + expect(flags.experimentalOxfmt).toBe(true) + }), + ) + + it.effect("experimentalOxfmt inherits OPENCODE_EXPERIMENTAL", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe( + Effect.provide( + fromConfig({ + OPENCODE_EXPERIMENTAL: "true", + }), + ), + ) + + expect(flags.experimentalOxfmt).toBe(true) + }), + ) + + for (const input of [ + { name: "absent", config: {}, expected: undefined }, + { + name: "valid positive integer", + config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234" }, + expected: 1234, + }, + { + name: "invalid string", + config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "nope" }, + expected: undefined, + }, + { name: "zero", config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "0" }, expected: undefined }, + { name: "negative", config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "-1" }, expected: undefined }, + { + name: "non-integer", + config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1.5" }, + expected: undefined, + }, + ]) { + it.effect(`parses bashDefaultTimeoutMs from config: ${input.name}`, () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig(input.config))) + + expect(flags.bashDefaultTimeoutMs).toBe(input.expected) + }), + ) + } + + for (const input of [ + { name: "absent", config: {}, expected: undefined }, + { + name: "valid positive integer", + config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "1234" }, + expected: 1234, + }, + { + name: "invalid string", + config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "nope" }, + expected: undefined, + }, + { name: "zero", config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "0" }, expected: undefined }, + { name: "negative", config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "-1" }, expected: undefined }, + { + name: "non-integer", + config: { OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "1.5" }, + expected: undefined, + }, + ]) { + it.effect(`parses outputTokenMax from config: ${input.name}`, () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig(input.config))) + + expect(flags.outputTokenMax).toBe(input.expected) + }), + ) + } + + it.effect("layer ignores the active ConfigProvider for omitted test overrides", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe( + Effect.provide(RuntimeFlags.layer()), + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_PURE: "true", + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", + OPENCODE_DISABLE_EXTERNAL_SKILLS: "true", + OPENCODE_DISABLE_LSP_DOWNLOAD: "true", + OPENCODE_EXPERIMENTAL: "true", + OPENCODE_ENABLE_EXA: "true", + OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234", + OPENCODE_CLIENT: "desktop", + }), + ), + ), + ) + + expect(flags.pure).toBe(false) + expect(flags.disableDefaultPlugins).toBe(false) + expect(flags.disableEmbeddedWebUi).toBe(false) + expect(flags.disableExternalSkills).toBe(false) + expect(flags.disableLspDownload).toBe(false) + expect(flags.disableClaudeCodePrompt).toBe(false) + expect(flags.disableClaudeCodeSkills).toBe(false) + expect(flags.enableExa).toBe(false) + expect(flags.experimentalIconDiscovery).toBe(false) + expect(flags.experimentalOxfmt).toBe(false) + expect(flags.outputTokenMax).toBeUndefined() + expect(flags.bashDefaultTimeoutMs).toBeUndefined() + expect(flags.client).toBe("cli") + }), + ) + + it.effect("disableClaudeCodeSkills defaults to false", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({}))) + + expect(flags.disableClaudeCodeSkills).toBe(false) + }), + ) + + it.effect("disableClaudeCodeSkills reads OPENCODE_DISABLE_CLAUDE_CODE_SKILLS", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "true" }))) + + expect(flags.disableClaudeCodeSkills).toBe(true) + }), + ) + + it.effect("disableClaudeCodeSkills inherits OPENCODE_DISABLE_CLAUDE_CODE", () => + Effect.gen(function* () { + const flags = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_DISABLE_CLAUDE_CODE: "true" }))) + + expect(flags.disableClaudeCodeSkills).toBe(true) + }), + ) +}) diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..da0f80cee5d3dbcec5db7d9371b9ca7b825d309c --- /dev/null +++ b/packages/opencode/test/event-manifest.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { EventManifest as SchemaEventManifest } from "@opencode-ai/schema/event-manifest" +import { Todo } from "@/session/todo" +import { EventManifest } from "@/event-manifest" + +describe("public event manifest", () => { + test("contains every latest public wire type once", () => { + expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions) + expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest) + expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable) + expect(EventManifest.Latest.size).toBe(88) + expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated) + expect(EventManifest.Latest.has("ide.installed")).toBe(false) + expect(EventManifest.Latest.has("server.connected")).toBe(true) + expect(EventManifest.Latest.has("global.disposed")).toBe(true) + }) + + test("contains only the current step settlement versions", () => { + expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) + expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) + }) +}) diff --git a/packages/opencode/test/filesystem/filesystem.test.ts b/packages/opencode/test/filesystem/filesystem.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1ccc1f3eeadde33a4a5a509450f6bf421cce9ca --- /dev/null +++ b/packages/opencode/test/filesystem/filesystem.test.ts @@ -0,0 +1,318 @@ +import { describe, test, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { testEffect } from "../lib/effect" +import path from "path" + +const { effect: it } = testEffect(LayerNode.compile(FSUtil.node)) + +describe("FSUtil", () => { + describe("isDir", () => { + it( + "returns true for directories", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + expect(yield* fs.isDir(tmp)).toBe(true) + }), + ) + + it( + "returns false for files", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + const file = path.join(tmp, "test.txt") + yield* fs.writeFileString(file, "hello") + expect(yield* fs.isDir(file)).toBe(false) + }), + ) + + it( + "returns false for non-existent paths", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false) + }), + ) + }) + + describe("isFile", () => { + it( + "returns true for files", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + const file = path.join(tmp, "test.txt") + yield* fs.writeFileString(file, "hello") + expect(yield* fs.isFile(file)).toBe(true) + }), + ) + + it( + "returns false for directories", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + expect(yield* fs.isFile(tmp)).toBe(false) + }), + ) + }) + + describe("readJson / writeJson", () => { + it( + "round-trips JSON data", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + const file = path.join(tmp, "data.json") + const data = { name: "test", count: 42, nested: { ok: true } } + + yield* fs.writeJson(file, data) + const result = yield* fs.readJson(file) + + expect(result).toEqual(data) + }), + ) + }) + + describe("ensureDir", () => { + it( + "creates nested directories", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + const nested = path.join(tmp, "a", "b", "c") + + yield* fs.ensureDir(nested) + + const info = yield* fs.stat(nested) + expect(info.type).toBe("Directory") + }), + ) + + it( + "is idempotent", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + const dir = path.join(tmp, "existing") + yield* fs.makeDirectory(dir) + + yield* fs.ensureDir(dir) + + const info = yield* fs.stat(dir) + expect(info.type).toBe("Directory") + }), + ) + }) + + describe("writeWithDirs", () => { + it( + "creates parent directories if missing", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + const file = path.join(tmp, "deep", "nested", "file.txt") + + yield* fs.writeWithDirs(file, "hello") + + expect(yield* fs.readFileString(file)).toBe("hello") + }), + ) + + it( + "writes directly when parent exists", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + const file = path.join(tmp, "direct.txt") + + yield* fs.writeWithDirs(file, "world") + + expect(yield* fs.readFileString(file)).toBe("world") + }), + ) + + it( + "writes Uint8Array content", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + const file = path.join(tmp, "binary.bin") + const content = new Uint8Array([0x00, 0x01, 0x02, 0x03]) + + yield* fs.writeWithDirs(file, content) + + const result = yield* fs.readFile(file) + expect(new Uint8Array(result)).toEqual(content) + }), + ) + }) + + describe("findUp", () => { + it( + "finds target in start directory", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(path.join(tmp, "target.txt"), "found") + + const result = yield* fs.findUp("target.txt", tmp) + expect(result).toEqual([path.join(tmp, "target.txt")]) + }), + ) + + it( + "finds target in parent directories", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(path.join(tmp, "marker"), "root") + const child = path.join(tmp, "a", "b") + yield* fs.makeDirectory(child, { recursive: true }) + + const result = yield* fs.findUp("marker", child, tmp) + expect(result).toEqual([path.join(tmp, "marker")]) + }), + ) + + it( + "returns empty array when not found", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + const result = yield* fs.findUp("nonexistent", tmp, tmp) + expect(result).toEqual([]) + }), + ) + }) + + describe("up", () => { + it( + "finds multiple targets walking up", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(path.join(tmp, "a.txt"), "a") + yield* fs.writeFileString(path.join(tmp, "b.txt"), "b") + const child = path.join(tmp, "sub") + yield* fs.makeDirectory(child) + yield* fs.writeFileString(path.join(child, "a.txt"), "a-child") + + const result = yield* fs.up({ targets: ["a.txt", "b.txt"], start: child, stop: tmp }) + + expect(result).toContain(path.join(child, "a.txt")) + expect(result).toContain(path.join(tmp, "a.txt")) + expect(result).toContain(path.join(tmp, "b.txt")) + }), + ) + }) + + describe("glob", () => { + it( + "finds files matching pattern", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(path.join(tmp, "a.ts"), "a") + yield* fs.writeFileString(path.join(tmp, "b.ts"), "b") + yield* fs.writeFileString(path.join(tmp, "c.json"), "c") + + const result = yield* fs.glob("*.ts", { cwd: tmp }) + expect(result.sort()).toEqual(["a.ts", "b.ts"]) + }), + ) + + it( + "supports absolute paths", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(path.join(tmp, "file.txt"), "hello") + + const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true }) + expect(result).toEqual([path.join(tmp, "file.txt")]) + }), + ) + }) + + describe("globMatch", () => { + it( + "matches patterns", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + expect(fs.globMatch("*.ts", "foo.ts")).toBe(true) + expect(fs.globMatch("*.ts", "foo.json")).toBe(false) + expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true) + }), + ) + }) + + describe("globUp", () => { + it( + "finds files walking up directories", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(path.join(tmp, "root.md"), "root") + const child = path.join(tmp, "a", "b") + yield* fs.makeDirectory(child, { recursive: true }) + yield* fs.writeFileString(path.join(child, "leaf.md"), "leaf") + + const result = yield* fs.globUp("*.md", child, tmp) + expect(result).toContain(path.join(child, "leaf.md")) + expect(result).toContain(path.join(tmp, "root.md")) + }), + ) + }) + + describe("built-in passthrough", () => { + it( + "exists works", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + const file = path.join(tmp, "exists.txt") + yield* fs.writeFileString(file, "yes") + + expect(yield* fs.exists(file)).toBe(true) + expect(yield* fs.exists(file + ".nope")).toBe(false) + }), + ) + + it( + "remove works", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const tmp = yield* fs.makeTempDirectoryScoped() + const file = path.join(tmp, "delete-me.txt") + yield* fs.writeFileString(file, "bye") + + yield* fs.remove(file) + + expect(yield* fs.exists(file)).toBe(false) + }), + ) + }) + + describe("pure helpers", () => { + test("mimeType returns correct types", () => { + expect(FSUtil.mimeType("file.json")).toBe("application/json") + expect(FSUtil.mimeType("image.png")).toBe("image/png") + expect(FSUtil.mimeType("unknown.qzx")).toBe("application/octet-stream") + }) + + test("contains checks path containment", () => { + expect(FSUtil.contains("/a/b", "/a/b/c")).toBe(true) + expect(FSUtil.contains("/a/b", "/a/c")).toBe(false) + }) + + test("overlaps detects overlapping paths", () => { + expect(FSUtil.overlaps("/a/b", "/a/b/c")).toBe(true) + expect(FSUtil.overlaps("/a/b/c", "/a/b")).toBe(true) + expect(FSUtil.overlaps("/a", "/b")).toBe(false) + }) + }) +}) diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d4b4cba31a604851216a67c08a3b9cb2a5986f7 --- /dev/null +++ b/packages/opencode/test/installation/installation.test.ts @@ -0,0 +1,240 @@ +import { describe, expect } from "bun:test" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { Effect, Layer, Stream } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { Installation } from "../../src/installation" +import { InstallationChannel } from "@opencode-ai/core/installation/version" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { testEffect } from "../lib/effect" + +const encoder = new TextEncoder() + +function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) { + const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request)))) + return Layer.succeed(HttpClient.HttpClient, client) +} + +function mockSpawner( + handler: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string } = () => + "", +) { + const spawner = ChildProcessSpawner.make((command) => { + const std = ChildProcess.isStandardCommand(command) ? command : undefined + const result = handler(std?.command ?? "", std?.args ?? []) + const output = typeof result === "string" ? { code: 0, stdout: result, stderr: "" } : result + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(0), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(output.code)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, + stdout: output.stdout ? Stream.make(encoder.encode(output.stdout)) : Stream.empty, + stderr: output.stderr ? Stream.make(encoder.encode(output.stderr)) : Stream.empty, + all: Stream.empty, + getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ) + }) + return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner) +} + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }) +} + +function testLayer( + httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response, + spawnHandler?: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string }, +) { + const spawnerNode = makeGlobalNode({ + service: ChildProcessSpawner.ChildProcessSpawner, + layer: mockSpawner(spawnHandler), + deps: [], + }) + return LayerNode.compile(Installation.node, [ + [httpClient, mockHttpClient(httpHandler)], + [CrossSpawnSpawner.node, spawnerNode], + ]) +} + +describe("installation", () => { + describe("latest", () => { + testEffect(testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))).effect( + "reads release version from GitHub releases", + () => + Effect.gen(function* () { + const result = yield* Installation.use.latest("unknown") + expect(result).toBe("1.2.3") + }), + ) + + testEffect(testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))).effect( + "strips v prefix from GitHub release tag", + () => + Effect.gen(function* () { + const result = yield* Installation.use.latest("curl") + expect(result).toBe("4.0.0-beta.1") + }), + ) + + const npmCalls: string[] = [] + testEffect( + testLayer((request) => { + npmCalls.push(request.url) + return jsonResponse({ version: "1.5.0" }) + }), + ).effect("reads npm versions via registry", () => + Effect.gen(function* () { + const result = yield* Installation.use.latest("npm") + expect(result).toBe("1.5.0") + expect(npmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) + }), + ) + + const bunCalls: string[] = [] + testEffect( + testLayer((request) => { + bunCalls.push(request.url) + return jsonResponse({ version: "1.6.0" }) + }), + ).effect("reads bun versions via registry", () => + Effect.gen(function* () { + const result = yield* Installation.use.latest("bun") + expect(result).toBe("1.6.0") + expect(bunCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) + }), + ) + + const pnpmCalls: string[] = [] + testEffect( + testLayer((request) => { + pnpmCalls.push(request.url) + return jsonResponse({ version: "1.7.0" }) + }), + ).effect("reads pnpm versions via registry", () => + Effect.gen(function* () { + const result = yield* Installation.use.latest("pnpm") + expect(result).toBe("1.7.0") + expect(pnpmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) + }), + ) + + testEffect(testLayer(() => jsonResponse({ version: "2.3.4" }))).effect("reads scoop manifest versions", () => + Effect.gen(function* () { + const result = yield* Installation.use.latest("scoop") + expect(result).toBe("2.3.4") + }), + ) + + testEffect(testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))).effect( + "reads chocolatey feed versions", + () => + Effect.gen(function* () { + const result = yield* Installation.use.latest("choco") + expect(result).toBe("3.4.5") + }), + ) + + testEffect( + testLayer( + () => jsonResponse({ versions: { stable: "2.0.0" } }), + (cmd, args) => { + // getBrewFormula: return core formula (no tap) + if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return "" + if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode" + return "" + }, + ), + ).effect("reads brew formulae API versions", () => + Effect.gen(function* () { + const result = yield* Installation.use.latest("brew") + expect(result).toBe("2.0.0") + }), + ) + + const brewInfoJson = JSON.stringify({ + formulae: [{ versions: { stable: "2.1.0" } }], + }) + testEffect( + testLayer( + () => jsonResponse({}), // HTTP not used for tap formula + (cmd, args) => { + if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "opencode" + if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson + return "" + }, + ), + ).effect("reads brew tap info JSON via CLI", () => + Effect.gen(function* () { + const result = yield* Installation.use.latest("brew") + expect(result).toBe("2.1.0") + }), + ) + }) + + describe("upgrade", () => { + testEffect( + testLayer( + () => jsonResponse({}), + (cmd) => { + if (cmd === "npm") return { code: 1, stderr: "token=secret command output" } + return "" + }, + ), + ).effect("returns sanitized typed errors for failed package upgrades", () => + Effect.gen(function* () { + const error = yield* Effect.flip(Installation.use.upgrade("npm", "9.9.9")) + expect(error).toBeInstanceOf(Installation.UpgradeFailedError) + expect(error.stderr).toBe("Upgrade failed for npm (exit code 1).") + expect(error.message).toBe(error.stderr) + expect(error.stderr).not.toContain("secret") + expect(error.stderr).not.toContain("command output") + }), + ) + + testEffect( + testLayer( + () => new Response("install script with token=secret", { status: 200 }), + (cmd, args) => { + if (cmd === "bash" && args[0] === "--version") return "GNU bash" + if (cmd === "bash" || cmd === "sh") return { code: 1, stderr: "script output with token=secret" } + return "" + }, + ), + ).effect("returns sanitized typed errors when the curl install script fails", () => + Effect.gen(function* () { + const error = yield* Effect.flip(Installation.use.upgrade("curl", "9.9.9")) + expect(error).toBeInstanceOf(Installation.UpgradeFailedError) + expect(error.stderr).toBe("Upgrade failed for curl (exit code 1).") + expect(error.message).toBe(error.stderr) + expect(error.stderr).not.toContain("secret") + expect(error.stderr).not.toContain("script output") + }), + ) + + testEffect( + testLayer( + () => new Response("install script", { status: 200 }), + (cmd, args) => { + if (cmd === "bash" && args[0] === "--version") return { code: 1, stderr: "missing" } + if (cmd === "bash") return { code: 1, stderr: "should not execute installer with bash" } + if (cmd === "sh") return "ok" + return "" + }, + ), + ).effect("falls back to sh when bash is unavailable during curl upgrade", () => + Effect.gen(function* () { + yield* Installation.use.upgrade("curl", "9.9.9") + }), + ) + }) +}) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3fabacf9fa8e352984efa48d3d023b67aa4900b5 --- /dev/null +++ b/packages/opencode/test/lsp/client.test.ts @@ -0,0 +1,488 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import { tmpdir, withTestInstance } from "../fixture/fixture" +import { LSPClient } from "@/lsp/client" +import * as LSPServer from "@/lsp/server" + +function spawnFakeServer() { + const { spawn } = require("child_process") + const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") + return { + process: spawn(process.execPath, [serverPath], { + stdio: "pipe", + }), + } +} + +describe("LSPClient interop", () => { + test("handles workspace/workspaceFolders request", async () => { + const handle = spawnFakeServer() as any + + const client = await withTestInstance({ + directory: process.cwd(), + fn: (ctx) => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + directory: process.cwd(), + instance: ctx, + }), + }) + + await client.connection.sendNotification("test/trigger", { + method: "workspace/workspaceFolders", + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(client.connection).toBeDefined() + await client.shutdown() + }) + + test("handles client/registerCapability request", async () => { + const handle = spawnFakeServer() as any + + const client = await withTestInstance({ + directory: process.cwd(), + fn: (ctx) => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + directory: process.cwd(), + instance: ctx, + }), + }) + + await client.connection.sendNotification("test/trigger", { + method: "client/registerCapability", + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(client.connection).toBeDefined() + await client.shutdown() + }) + + test("handles client/unregisterCapability request", async () => { + const handle = spawnFakeServer() as any + + const client = await withTestInstance({ + directory: process.cwd(), + fn: (ctx) => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + directory: process.cwd(), + instance: ctx, + }), + }) + + await client.connection.sendNotification("test/trigger", { + method: "client/unregisterCapability", + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(client.connection).toBeDefined() + await client.shutdown() + }) + + test("initialize does not overclaim unsupported diagnostics capabilities", async () => { + const handle = spawnFakeServer() as any + + const client = await withTestInstance({ + directory: process.cwd(), + fn: (ctx) => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + directory: process.cwd(), + instance: ctx, + }), + }) + + const params = await client.connection.sendRequest("test/get-initialize-params", {}) + expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false) + expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false) + + await client.shutdown() + }) + + test("workspace/configuration returns one result per requested item", async () => { + const handle = spawnFakeServer() as any + const initialization = { + alpha: { + beta: 1, + }, + gamma: true, + } + + const client = await withTestInstance({ + directory: process.cwd(), + fn: (ctx) => + LSPClient.create({ + serverID: "fake", + server: { + ...(handle as unknown as LSPServer.Handle), + initialization, + }, + root: process.cwd(), + directory: process.cwd(), + instance: ctx, + }), + }) + + const response = await client.connection.sendRequest("test/request-configuration", { + items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}], + }) + + expect(response).toEqual([{ beta: 1 }, 1, null, initialization]) + + await client.shutdown() + }) + + test("sends ranged didChange for incremental sync servers", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.ts") + await Bun.write(file, "first\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + await client.notify.open({ path: file }) + await Bun.write(file, "second\nthird\n") + await client.notify.open({ path: file }) + + const change = await client.connection.sendRequest<{ + textDocument: { version: number } + contentChanges: { + range?: { start: { line: number; character: number }; end: { line: number; character: number } } + text: string + }[] + }>("test/get-last-change", {}) + expect(change.textDocument.version).toBe(1) + expect(change.contentChanges).toEqual([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 0 }, + }, + text: "second\nthird\n", + }, + ]) + + await client.shutdown() + }, + }) + }) + + test("document mode falls back to push diagnostics", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.ts") + await Bun.write(file, "const x = 1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + const version = await client.notify.open({ path: file }) + const wait = client.waitForDiagnostics({ path: file, version, mode: "document" }) + await client.connection.sendNotification("test/publish-diagnostics", { + uri: pathToFileURL(file).href, + version, + diagnostics: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "push diagnostic", + severity: 1, + }, + ], + }) + await wait + + const diagnostics = client.diagnostics.get(file) ?? [] + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.message).toBe("push diagnostic") + + const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {}) + expect(count).toBe(0) + + await client.shutdown() + }, + }) + }) + + test("document mode accepts matching push diagnostics published before waiting", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.ts") + await Bun.write(file, "const x = 1\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + const version = await client.notify.open({ path: file }) + await client.connection.sendNotification("test/publish-diagnostics", { + uri: pathToFileURL(file).href, + version, + diagnostics: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "push diagnostic", + severity: 1, + }, + ], + }) + + for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + + expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic") + + const started = Date.now() + await client.waitForDiagnostics({ path: file, version, mode: "document" }) + expect(Date.now() - started).toBeLessThan(1_000) + + await client.shutdown() + }, + }) + }) + + test("document mode waits for pull diagnostics", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + await Bun.write(file, "class C {}\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + registrations: [{ identifier: "DocumentCompilerSemantic" }], + documentDiagnosticsByIdentifier: { + DocumentCompilerSemantic: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "pull diagnostic", + severity: 1, + }, + ], + }, + }) + + const version = await client.notify.open({ path: file }) + await client.waitForDiagnostics({ path: file, version, mode: "document" }) + + const diagnostics = client.diagnostics.get(file) ?? [] + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.message).toBe("pull diagnostic") + + const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {}) + expect(count).toBeGreaterThan(0) + + await client.shutdown() + }, + }) + }) + + test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + await Bun.write(file, "class C {}\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registrations: [{ identifier: "fast" }, { identifier: "slow" }], + documentDiagnosticsByIdentifier: { + fast: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "fast diagnostic", + severity: 1, + }, + ], + slow: [], + }, + documentDelayMsByIdentifier: { + slow: 2_500, + }, + }) + + const version = await client.notify.open({ path: file }) + await client.connection.sendRequest("test/register-configured-pull-diagnostics", {}) + await new Promise((resolve) => setTimeout(resolve, 100)) + const started = Date.now() + await client.waitForDiagnostics({ path: file, version, mode: "document" }) + + expect(Date.now() - started).toBeLessThan(1_000) + expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic") + expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1) + + await client.shutdown() + }, + }) + }) + + test("full mode includes workspace pull diagnostics", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + const related = path.join(tmp.path, "other.cs") + await Bun.write(file, "class C {}\n") + await Bun.write(related, "class D {}\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + registrations: [ + { identifier: "DocumentCompilerSemantic" }, + { identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }, + ], + documentDiagnosticsByIdentifier: { + DocumentCompilerSemantic: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "current file", + severity: 1, + }, + ], + }, + workspaceDiagnosticsByIdentifier: { + WorkspaceDocumentsAndProject: [ + { + uri: pathToFileURL(related).href, + items: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "workspace file", + severity: 1, + }, + ], + }, + ], + }, + }) + + const version = await client.notify.open({ path: file }) + await client.waitForDiagnostics({ path: file, version, mode: "full" }) + + expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file") + expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file") + + await client.shutdown() + }, + }) + }) + + test("full mode treats an empty workspace pull response as handled", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + await Bun.write(file, "class C {}\n") + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }], + workspaceDiagnosticsByIdentifier: { + WorkspaceDocumentsAndProject: [], + }, + }) + + const version = await client.notify.open({ path: file }) + const started = Date.now() + await client.waitForDiagnostics({ path: file, version, mode: "full" }) + + expect(Date.now() - started).toBeLessThan(1_000) + + await client.shutdown() + }, + }) + }) +}) diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..57a7b59d1cfeff18d30c786464668c583bba6611 --- /dev/null +++ b/packages/opencode/test/lsp/index.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, spyOn } from "bun:test" +import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Deferred, Effect, Layer } from "effect" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { LSP } from "@/lsp/lsp" +import * as LSPServer from "@/lsp/server" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { TestInstance } from "../fixture/fixture" +import { awaitWithTimeout, testEffect } from "../lib/effect" + +const lspLayer = (flags: Parameters[0] = {}) => + LayerNode.compile(LayerNode.group([LSP.node, Config.node, RuntimeFlags.node, EventV2Bridge.node]), [ + [RuntimeFlags.node, RuntimeFlags.layer(flags)], + ]) + +const it = testEffect(Layer.mergeAll(lspLayer(), LayerNode.compile(CrossSpawnSpawner.node))) +const experimentalTyIt = testEffect( + Layer.mergeAll(lspLayer({ experimentalLspTy: true }), LayerNode.compile(CrossSpawnSpawner.node)), +) +const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") +const disabledDownloadIt = testEffect( + Layer.mergeAll(lspLayer({ disableLspDownload: true }), LayerNode.compile(CrossSpawnSpawner.node)), +) + +describe("lsp.spawn", () => { + it.instance( + "does not spawn builtin LSP for files outside instance", + () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + + try { + yield* lsp.touchFile(path.join(dir, "..", "outside.ts")) + yield* lsp.hover({ + file: path.join(dir, "..", "hover.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(0) + } finally { + spy.mockRestore() + } + }), + ), + { config: { lsp: true } }, + ) + + it.instance("does not spawn builtin LSP for files inside instance when LSP is unset", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(0) + } finally { + spy.mockRestore() + } + }), + ), + ) + + it.instance( + "would spawn builtin LSP for files inside instance when lsp is true", + () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + spy.mockRestore() + } + }), + ), + { config: { lsp: true } }, + ) + + it.instance( + "publishes lsp.updated after custom LSP initialization", + () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const lsp = yield* LSP.Service + const updated = yield* Deferred.make() + const events = yield* EventV2Bridge.Service + const unsubscribe = yield* events.listen((event) => { + if (event.type === LSP.Event.Updated.type) Deferred.doneUnsafe(updated, Effect.void) + return Effect.void + }) + yield* Effect.addFinalizer(() => unsubscribe) + + const file = path.join(dir, "sample.repro") + yield* Effect.promise(() => Bun.write(file, "sample\n")) + yield* lsp.touchFile(file) + yield* awaitWithTimeout(Deferred.await(updated), "lsp.updated event was not published") + }), + { + config: { + lsp: { + fake: { + command: [process.execPath, fakeServerPath], + extensions: [".repro"], + }, + }, + }, + }, + ) + + it.instance( + "would spawn builtin LSP for files inside instance when config object is provided", + () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + spy.mockRestore() + } + }), + ), + { + config: { + lsp: { + eslint: { disabled: true }, + }, + }, + }, + ) + + it.instance( + "uses pyright instead of ty by default", + () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined) + const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) + + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.py"), + line: 0, + character: 0, + }) + expect(ty).toHaveBeenCalledTimes(0) + expect(pyright).toHaveBeenCalledTimes(1) + } finally { + ty.mockRestore() + pyright.mockRestore() + } + }), + ), + { config: { lsp: true } }, + ) + + experimentalTyIt.instance( + "uses ty instead of pyright when experimentalLspTy is enabled", + () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined) + const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) + + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.py"), + line: 0, + character: 0, + }) + expect(ty).toHaveBeenCalledTimes(1) + expect(pyright).toHaveBeenCalledTimes(0) + } finally { + ty.mockRestore() + pyright.mockRestore() + } + }), + ), + { config: { lsp: true } }, + ) + + disabledDownloadIt.instance( + "passes disableLspDownload to builtin LSP spawn", + () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) + + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.py"), + line: 0, + character: 0, + }) + expect(pyright).toHaveBeenCalledTimes(1) + expect(pyright.mock.calls[0]?.[2]).toMatchObject({ disableLspDownload: true }) + } finally { + pyright.mockRestore() + } + }), + ), + { config: { lsp: true } }, + ) +}) diff --git a/packages/opencode/test/lsp/jdtls-root.test.ts b/packages/opencode/test/lsp/jdtls-root.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4fd7472a153a611d1286fc1b74bbfe9b5bab6d66 --- /dev/null +++ b/packages/opencode/test/lsp/jdtls-root.test.ts @@ -0,0 +1,459 @@ +import { describe, test, expect, afterAll } from "bun:test" +import path from "path" +import fs from "fs/promises" +import os from "os" +import * as LSPServer from "@/lsp/server" +import type { InstanceContext } from "@/project/instance-context" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const tmpBase = path.join(os.tmpdir(), "opencode-jdtls-test") + +function makeCtx(directory: string): InstanceContext { + return { directory, worktree: "/", project: {} as any } +} + +async function mkdirp(p: string) { + await fs.mkdir(p, { recursive: true }) +} + +async function touch(p: string) { + await mkdirp(path.dirname(p)) + await fs.writeFile(p, "", "utf-8") +} + +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- + +afterAll(async () => { + await fs.rm(tmpBase, { recursive: true, force: true }).catch(() => {}) +}) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("JDTLS.root", () => { + // ------------------------------------------------------------------------- + // Maven + // ------------------------------------------------------------------------- + describe("Maven", () => { + test("single-module Maven project returns pom.xml directory", async () => { + const root = path.join(tmpBase, "single-maven") + await mkdirp(root) + await touch(path.join(root, "pom.xml")) + const srcDir = path.join(root, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(root)) + expect(result).toBe(root) + }) + + test("multi-module Maven project follows chain to top-level pom.xml", async () => { + const root = path.join(tmpBase, "multi-maven") + await mkdirp(root) + // Parent pom with module-a + await Bun.write(path.join(root, "pom.xml"), "module-a") + // Child module with its own pom.xml + const childDir = path.join(root, "module-a") + await mkdirp(childDir) + await touch(path.join(childDir, "pom.xml")) + const srcDir = path.join(childDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(root)) + // Parent declares module-a as module → root is parent directory + expect(result).toBe(root) + }) + + test("Maven project inside a nested directory (ctx.directory is workspace root)", async () => { + // Workspace root = ctx.directory, Maven project in a subdirectory + const workspace = path.join(tmpBase, "maven-workspace") + await mkdirp(workspace) + const projectDir = path.join(workspace, "my-maven-app") + await touch(path.join(projectDir, "pom.xml")) + const srcDir = path.join(projectDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(workspace)) + // findUp finds projectDir's pom.xml (only one), returns projectDir + expect(result).toBe(projectDir) + }) + + test("nested independent Maven project stops at its own pom.xml", async () => { + const workspace = path.join(tmpBase, "nested-independent") + await mkdirp(workspace) + // Parent pom WITHOUT tools/sample + await Bun.write( + path.join(workspace, "pom.xml"), + "module-a", + ) + // Independent project nested inside + const projectDir = path.join(workspace, "tools", "sample") + await mkdirp(projectDir) + await touch(path.join(projectDir, "pom.xml")) + const srcDir = path.join(projectDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(workspace)) + // workspace/pom.xml does NOT declare tools/sample as module → stop at tools/sample + expect(result).toBe(projectDir) + }) + + test("three-level Maven module chain resolves to top-level", async () => { + const root = path.join(tmpBase, "three-level") + await mkdirp(root) + await Bun.write(path.join(root, "pom.xml"), "apps") + const appsDir = path.join(root, "apps") + await mkdirp(appsDir) + await Bun.write(path.join(appsDir, "pom.xml"), "my-app") + const appDir = path.join(appsDir, "my-app") + await mkdirp(appDir) + await touch(path.join(appDir, "pom.xml")) + const srcDir = path.join(appDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(root)) + expect(result).toBe(root) + }) + + test("three-level Maven chain stops when link is broken", async () => { + const root = path.join(tmpBase, "broken-chain") + await mkdirp(root) + await Bun.write(path.join(root, "pom.xml"), "apps") + const appsDir = path.join(root, "apps") + await mkdirp(appsDir) + await touch(path.join(appsDir, "pom.xml")) // Empty pom, no declaration + const appDir = path.join(appsDir, "my-app") + await mkdirp(appDir) + await touch(path.join(appDir, "pom.xml")) + const srcDir = path.join(appDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(root)) + // apps/pom.xml has no my-app → stop at my-app + expect(result).toBe(appDir) + }) + + test(" with ./ prefix is normalized correctly", async () => { + const root = path.join(tmpBase, "dot-slash-module") + await mkdirp(root) + await Bun.write(path.join(root, "pom.xml"), "./module-a") + const childDir = path.join(root, "module-a") + await mkdirp(childDir) + await touch(path.join(childDir, "pom.xml")) + const srcDir = path.join(childDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(root)) + expect(result).toBe(root) + }) + + test(" with trailing slash is normalized correctly", async () => { + const root = path.join(tmpBase, "trailing-slash-module") + await mkdirp(root) + await Bun.write(path.join(root, "pom.xml"), "module-a/") + const childDir = path.join(root, "module-a") + await mkdirp(childDir) + await touch(path.join(childDir, "pom.xml")) + const srcDir = path.join(childDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(root)) + expect(result).toBe(root) + }) + }) + + // ------------------------------------------------------------------------- + // Gradle + // ------------------------------------------------------------------------- + describe("Gradle", () => { + test("Gradle project with settings.gradle in a subdirectory of ctx.directory", async () => { + // Workspace root = ctx.directory, Gradle project in a subdirectory + const workspace = path.join(tmpBase, "gradle-sub") + await mkdirp(workspace) + const projectDir = path.join(workspace, "gradle-app") + await touch(path.join(projectDir, "settings.gradle")) + await touch(path.join(projectDir, "build.gradle")) + const srcDir = path.join(projectDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(workspace)) + expect(result).toBe(projectDir) + }) + + test("Gradle project with only build.gradle in a subdirectory", async () => { + const workspace = path.join(tmpBase, "gradle-build-sub") + await mkdirp(workspace) + const projectDir = path.join(workspace, "gradle-app") + await touch(path.join(projectDir, "build.gradle")) + const srcDir = path.join(projectDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(workspace)) + expect(result).toBe(projectDir) + }) + + test("Gradle monorepo with settings.gradle takes precedence over nested pom.xml", async () => { + const workspace = path.join(tmpBase, "gradle-monorepo") + await mkdirp(workspace) + const gradleRoot = path.join(workspace, "gradle-project") + await touch(path.join(gradleRoot, "settings.gradle")) + await touch(path.join(gradleRoot, "gradlew")) + // Submodule has pom.xml too + const subDir = path.join(gradleRoot, "module-a") + await mkdirp(subDir) + await touch(path.join(subDir, "pom.xml")) + const srcDir = path.join(subDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(workspace)) + // Gradle markers found at gradleRoot level + expect(result).toBe(gradleRoot) + }) + + test("settings.gradle.kts (Kotlin DSL) is recognized", async () => { + const workspace = path.join(tmpBase, "gradle-kts-settings") + await mkdirp(workspace) + const projectDir = path.join(workspace, "gradle-app") + await touch(path.join(projectDir, "settings.gradle.kts")) + const srcDir = path.join(projectDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(workspace)) + expect(result).toBe(projectDir) + }) + + test("build.gradle.kts (Kotlin DSL) is recognized", async () => { + const workspace = path.join(tmpBase, "gradle-kts-build") + await mkdirp(workspace) + const projectDir = path.join(workspace, "gradle-app") + await touch(path.join(projectDir, "build.gradle.kts")) + const srcDir = path.join(projectDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(workspace)) + expect(result).toBe(projectDir) + }) + + test("gradlew (without settings.gradle) in a subdirectory is recognized", async () => { + const workspace = path.join(tmpBase, "gradlew-sub") + await mkdirp(workspace) + const projectDir = path.join(workspace, "gradle-app") + await touch(path.join(projectDir, "gradlew")) + const srcDir = path.join(projectDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(workspace)) + expect(result).toBe(projectDir) + }) + + test("pom.xml is excluded when gradlew is present at same level", async () => { + const workspace = path.join(tmpBase, "gradle-excludes-maven") + await mkdirp(workspace) + const projectDir = path.join(workspace, "mixed-project") + // Both pom.xml and gradlew exist + await touch(path.join(projectDir, "pom.xml")) + await touch(path.join(projectDir, "gradlew")) + const srcDir = path.join(projectDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(workspace)) + // Gradle wrapper takes precedence + expect(result).toBe(projectDir) + }) + }) + + // ------------------------------------------------------------------------- + // Eclipse + // ------------------------------------------------------------------------- + describe("Eclipse", () => { + test("Eclipse project with .project in a subdirectory", async () => { + const workspace = path.join(tmpBase, "eclipse-sub") + await mkdirp(workspace) + const projectDir = path.join(workspace, "eclipse-app") + await touch(path.join(projectDir, ".project")) + await touch(path.join(projectDir, ".classpath")) + const srcDir = path.join(projectDir, "src", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(workspace)) + expect(result).toBe(projectDir) + }) + }) + + // ------------------------------------------------------------------------- + // No markers + // ------------------------------------------------------------------------- + describe("No build markers", () => { + test("Java file with no build markers returns undefined", async () => { + const root = path.join(tmpBase, "no-build") + await mkdirp(root) + const srcDir = path.join(root, "src") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(root)) + expect(result).toBeUndefined() + }) + }) + + // ------------------------------------------------------------------------- + // Additional validation scenarios + // ------------------------------------------------------------------------- + describe("Additional Maven module-chain validation", () => { + // Scenario 1: with multi-segment path (e.g. tools/sample) + test(" multi-segment path matches nested directory", async () => { + const root = path.join(tmpBase, "multi-seg-module") + await mkdirp(root) + await Bun.write(path.join(root, "pom.xml"), "tools/sample") + const childDir = path.join(root, "tools", "sample") + await mkdirp(childDir) + await touch(path.join(childDir, "pom.xml")) + const srcDir = path.join(childDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(root)) + expect(result).toBe(root) + }) + + // Scenario 2: declaration does not match actual directory name + test(" declaration mismatch does not falsely match", async () => { + const root = path.join(tmpBase, "module-mismatch") + await mkdirp(root) + // Parent declares module-a, but actual directory is module-b + await Bun.write(path.join(root, "pom.xml"), "module-a") + const childDir = path.join(root, "module-b") + await mkdirp(childDir) + await touch(path.join(childDir, "pom.xml")) + const srcDir = path.join(childDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(root)) + // module-b is not declared as a module → stop at module-b + expect(result).toBe(childDir) + }) + + // Scenario 3: Multiple declarations + test("multiple declarations allow second module to traverse up", async () => { + const root = path.join(tmpBase, "multi-modules") + await mkdirp(root) + await Bun.write( + path.join(root, "pom.xml"), + "module-amodule-b", + ) + const childA = path.join(root, "module-a") + await mkdirp(childA) + await touch(path.join(childA, "pom.xml")) + const childB = path.join(root, "module-b") + await mkdirp(childB) + await touch(path.join(childB, "pom.xml")) + const srcDir = path.join(childB, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(root)) + expect(result).toBe(root) + }) + + // Scenario 4: XML comments should not be matched as module declarations + test("XML-commented is not matched", async () => { + const root = path.join(tmpBase, "commented-module") + await mkdirp(root) + await Bun.write( + path.join(root, "pom.xml"), + "", + ) + const childDir = path.join(root, "module-a") + await mkdirp(childDir) + await touch(path.join(childDir, "pom.xml")) + const srcDir = path.join(childDir, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(root)) + // Commented-out module should not be matched → stop at module-a + expect(result).toBe(childDir) + }) + + // Scenario 5: pom.xml at ctx.directory itself + test("pom.xml at ctx.directory itself is found correctly", async () => { + const root = path.join(tmpBase, "pom-at-ctx") + await mkdirp(root) + await touch(path.join(root, "pom.xml")) + const srcDir = path.join(root, "src", "main", "java", "com", "example") + await mkdirp(srcDir) + await touch(path.join(srcDir, "App.java")) + + const file = path.join(srcDir, "App.java") + const result = await LSPServer.JDTLS.root(file, makeCtx(root)) + expect(result).toBe(root) + }) + + // Scenario 6: Mixed Gradle + Maven sibling projects don't interfere + test("Maven and Gradle sibling projects don't interfere", async () => { + const workspace = path.join(tmpBase, "mixed-siblings") + await mkdirp(workspace) + // Gradle project + const gradleDir = path.join(workspace, "gradle-project") + await touch(path.join(gradleDir, "settings.gradle")) + const gradleSrc = path.join(gradleDir, "src", "main", "java", "com", "example") + await mkdirp(gradleSrc) + await touch(path.join(gradleSrc, "GradleApp.java")) + // Maven project + const mavenDir = path.join(workspace, "maven-project") + await touch(path.join(mavenDir, "pom.xml")) + const mavenSrc = path.join(mavenDir, "src", "main", "java", "com", "example") + await mkdirp(mavenSrc) + await touch(path.join(mavenSrc, "MavenApp.java")) + + const gradleResult = await LSPServer.JDTLS.root(path.join(gradleSrc, "GradleApp.java"), makeCtx(workspace)) + expect(gradleResult).toBe(gradleDir) + + const mavenResult = await LSPServer.JDTLS.root(path.join(mavenSrc, "MavenApp.java"), makeCtx(workspace)) + expect(mavenResult).toBe(mavenDir) + }) + }) +}) diff --git a/packages/opencode/test/lsp/launch.test.ts b/packages/opencode/test/lsp/launch.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..258e92524d8640d7e97fe9b1763b6ccc0e8d6282 --- /dev/null +++ b/packages/opencode/test/lsp/launch.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { spawn } from "../../src/lsp/launch" +import { tmpdir } from "../fixture/fixture" + +describe("lsp.launch", () => { + test("spawns cmd scripts with spaces on Windows", async () => { + if (process.platform !== "win32") return + + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "with space") + const file = path.join(dir, "echo cmd.cmd") + + await fs.mkdir(dir, { recursive: true }) + await Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n") + + const proc = spawn(file, ["--stdio"]) + + expect(await proc.exited).toBe(0) + }) +}) diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..17387a6bf82b1b854877b00b2375e1754b95d9f3 --- /dev/null +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" +import { LSP } from "@/lsp/lsp" +import * as LSPServer from "@/lsp/server" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(LayerNode.compile(LSP.node)) + +describe("LSP service lifecycle", () => { + let spawnSpy: ReturnType + + beforeEach(() => { + spawnSpy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + }) + + afterEach(() => { + spawnSpy.mockRestore() + }) + + it.instance("init() completes without error", () => LSP.Service.use((lsp) => lsp.init())) + + it.instance("status() returns empty array initially", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.status() + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(0) + }), + ), + ) + + it.instance("diagnostics() returns empty object initially", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.diagnostics() + expect(typeof result).toBe("object") + expect(Object.keys(result).length).toBe(0) + }), + ), + ) + + it.instance("hasClients() returns false for .ts files in instance when LSP is unset", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts")) + expect(result).toBe(false) + }), + ), + ) + + it.instance( + "hasClients() returns true for .ts files in instance when lsp is true", + () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts")) + expect(result).toBe(true) + }), + ), + { config: { lsp: true } }, + ) + + it.instance( + "hasClients() keeps built-in LSPs when config object is provided", + () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts")) + expect(result).toBe(true) + }), + ), + { config: { lsp: { eslint: { disabled: true } } } }, + ) + + it.instance("hasClients() returns false for files outside instance", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "..", "outside.ts")) + expect(typeof result).toBe("boolean") + }), + ), + ) + + it.instance("workspaceSymbol() returns empty array with no clients", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.workspaceSymbol("test") + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(0) + }), + ), + ) + + it.instance("definition() returns empty array for unknown file", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.definition({ + file: path.join((yield* TestInstance).directory, "nonexistent.ts"), + line: 0, + character: 0, + }) + expect(Array.isArray(result)).toBe(true) + }), + ), + ) + + it.instance("references() returns empty array for unknown file", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.references({ + file: path.join((yield* TestInstance).directory, "nonexistent.ts"), + line: 0, + character: 0, + }) + expect(Array.isArray(result)).toBe(true) + }), + ), + ) + + it.instance("multiple init() calls are idempotent", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.init() + yield* lsp.init() + yield* lsp.init() + }), + ), + ) +}) + +describe("LSP.Diagnostic", () => { + test("pretty() formats error diagnostic", () => { + const result = LSP.Diagnostic.pretty({ + range: { start: { line: 9, character: 4 }, end: { line: 9, character: 10 } }, + message: "Type 'string' is not assignable to type 'number'", + severity: 1, + } as any) + expect(result).toBe("ERROR [10:5] Type 'string' is not assignable to type 'number'") + }) + + test("pretty() formats warning diagnostic", () => { + const result = LSP.Diagnostic.pretty({ + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, + message: "Unused variable", + severity: 2, + } as any) + expect(result).toBe("WARN [1:1] Unused variable") + }) + + test("pretty() defaults to ERROR when no severity", () => { + const result = LSP.Diagnostic.pretty({ + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, + message: "Something wrong", + } as any) + expect(result).toBe("ERROR [1:1] Something wrong") + }) +}) diff --git a/packages/opencode/test/mcp/auth.test.ts b/packages/opencode/test/mcp/auth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5aa099983a3152ae8655d54dad00332bc202375a --- /dev/null +++ b/packages/opencode/test/mcp/auth.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test" +import { setTimeout as sleep } from "node:timers/promises" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Effect, Layer } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { McpAuth } from "../../src/mcp/auth" + +function authFile() { + let raw = "" + let activeWrites = 0 + let sawOverlap = false + + const fsLayer = Layer.effect( + FSUtil.Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + + return FSUtil.Service.of({ + ...fs, + readJson: (file) => + file.endsWith("mcp-auth.json") + ? Effect.try({ + try: () => { + if (!raw) throw new Error("mcp-auth.json missing") + return JSON.parse(raw) + }, + catch: (cause) => new FSUtil.FileSystemError({ method: "readJson", cause }), + }) + : fs.readJson(file), + writeJson: (file, value, mode) => + file.endsWith("mcp-auth.json") + ? Effect.promise(async () => { + activeWrites++ + sawOverlap = sawOverlap || activeWrites > 1 + raw = "" + await sleep(10) + const next = JSON.stringify(value, null, 2) + raw = sawOverlap ? `${next}\n}` : next + activeWrites-- + }) + : fs.writeJson(file, value, mode), + }) + }), + ).pipe(Layer.provide(AppNodeBuilder.build(FSUtil.node))) + + return { fsLayer, raw: () => raw } +} + +function authService(fsLayer: Layer.Layer) { + return McpAuth.Service.use((auth) => Effect.succeed(auth)).pipe( + Effect.provide(AppNodeBuilder.build(McpAuth.node, [[FSUtil.node, fsLayer]])), + ) +} + +test("serializes concurrent auth file updates across service instances", async () => { + const file = authFile() + + await Effect.runPromise( + Effect.gen(function* () { + const first = yield* authService(file.fsLayer) + const second = yield* authService(file.fsLayer) + + yield* Effect.all( + [ + first.updateTokens("posthog", { accessToken: "access-token" }, "https://mcp.posthog.com/mcp"), + second.updateClientInfo("posthog", { clientId: "client-id" }, "https://mcp.posthog.com/mcp"), + ], + { concurrency: "unbounded" }, + ) + + const entry = yield* first.get("posthog") + expect(entry?.tokens?.accessToken).toBe("access-token") + expect(entry?.clientInfo?.clientId).toBe("client-id") + expect(entry?.serverUrl).toBe("https://mcp.posthog.com/mcp") + expect(() => JSON.parse(file.raw())).not.toThrow() + }), + ) +}) diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b0d6403bb1652b9e66ca0ea9e684bcd3ac1c13b --- /dev/null +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { McpCatalog } from "@/mcp/catalog" +import { Effect } from "effect" + +const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any + +function clientReturning(result: unknown) { + return { + callTool: async () => result, + } as unknown as Client +} + +function mcpTool() { + return { + name: "screenshot", + description: "Take a screenshot", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + } as any +} + +describe("McpCatalog.convertTool", () => { + test("preserves content when structuredContent is also present", async () => { + const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }] + const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } } + const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content, structuredContent })) + + const output = await converted.execute?.({}, options) + + expect(output).toMatchObject({ content, structuredContent }) + }) + + test("falls back to structuredContent only when content is absent", async () => { + const structuredContent = { results: [{ title: "one" }] } + const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent })) + + const output = await converted.execute?.({}, options) + + expect(output).toMatchObject({ + structuredContent, + content: [{ type: "text", text: JSON.stringify(structuredContent) }], + }) + }) +}) + +test("preserves output schema validation across paginated tool discovery", async () => { + const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, ({ params }) => + Promise.resolve( + params?.cursor === "page-2" + ? { + tools: [ + { + name: "second", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + }, + }, + ], + } + : { + tools: [ + { + name: "first", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + ], + nextCursor: "page-2", + }, + ), + ) + server.setRequestHandler(CallToolRequestSchema, ({ params }) => + Promise.resolve({ + content: [], + structuredContent: { value: params.name === "first" ? 42 : 1 }, + }), + ) + + const client = new Client({ name: "pagination-test", version: "1.0.0" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + + try { + const tools = await Effect.runPromise(McpCatalog.defs(client)) + expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"]) + await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow( + "Structured content does not match the tool's output schema", + ) + } finally { + await Promise.all([client.close(), server.close()]) + } +}) diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..31cfc20d51c63250fa14c56e54463f57d1654ba3 --- /dev/null +++ b/packages/opencode/test/mcp/headers.test.ts @@ -0,0 +1,102 @@ +import { describe, expect } from "bun:test" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" +import { testEffect } from "../lib/effect" +import { MCP } from "../../src/mcp/index" + +const it = testEffect(LayerNode.compile(MCP.node)) + +const serve = Effect.acquireRelease( + Effect.promise(async () => { + const requests: Headers[] = [] + const protocol = new Server({ name: "headers", version: "1.0.0" }, { capabilities: { tools: {} } }) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + }) + await protocol.connect(transport) + const http = Bun.serve({ + port: 0, + fetch(request) { + requests.push(new Headers(request.headers)) + return transport.handleRequest(request) + }, + }) + return { + requests, + url: http.url.toString(), + close: async () => { + await http.stop(true) + await protocol.close() + }, + } + }), + (server) => Effect.promise(server.close), +) + +describe("mcp.headers", () => { + it.instance("headers are passed to transports when oauth is enabled (default)", () => + Effect.gen(function* () { + const server = yield* serve + const mcp = yield* MCP.Service + const result = yield* mcp.add("test-server", { + type: "remote", + url: server.url, + headers: { + Authorization: "Bearer test-token", + "X-Custom-Header": "custom-value", + }, + }) + + expect(result.status).toMatchObject({ "test-server": { status: "connected" } }) + expect(server.requests.length).toBeGreaterThan(0) + for (const headers of server.requests) { + expect(headers.get("authorization")).toBe("Bearer test-token") + expect(headers.get("x-custom-header")).toBe("custom-value") + } + }), + ) + + it.instance("headers are passed to transports when oauth is explicitly disabled", () => + Effect.gen(function* () { + const server = yield* serve + const mcp = yield* MCP.Service + const result = yield* mcp.add("test-server-no-oauth", { + type: "remote", + url: server.url, + oauth: false, + headers: { + Authorization: "Bearer test-token", + }, + }) + + expect(result.status).toMatchObject({ "test-server-no-oauth": { status: "connected" } }) + expect(server.requests.length).toBeGreaterThan(0) + for (const headers of server.requests) { + expect(headers.get("authorization")).toBe("Bearer test-token") + } + }), + ) + + it.instance("no requestInit when headers are not provided", () => + Effect.gen(function* () { + const server = yield* serve + const mcp = yield* MCP.Service + const result = yield* mcp.add("test-server-no-headers", { + type: "remote", + url: server.url, + }) + + expect(result.status).toMatchObject({ "test-server-no-headers": { status: "connected" } }) + expect(server.requests.length).toBeGreaterThan(0) + for (const headers of server.requests) { + expect(headers.has("authorization")).toBe(false) + expect(headers.has("x-custom-header")).toBe(false) + } + }), + ) +}) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..80c8fd22f88603cd2f2977ec65b9f9b48200bae6 --- /dev/null +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -0,0 +1,560 @@ +import path from "node:path" +import { pathToFileURL } from "node:url" +import { expect } from "bun:test" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, + type ServerCapabilities, + type Tool, +} from "@modelcontextprotocol/sdk/types.js" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Cause, Effect, Exit } from "effect" +import type { MCP as MCPNS } from "../../src/mcp/index" +import { MCP } from "../../src/mcp/index" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" +import { TestInstance } from "../fixture/fixture" +import { pollWithTimeout, testEffect } from "../lib/effect" + +const it = testEffect(LayerNode.compile(MCP.node)) +const stdioFixture = path.join(import.meta.dir, "../fixture/mcp-lifecycle-stdio.ts") + +type Page = { items: T[]; nextCursor?: string } + +interface LifecycleServerState { + tools: Tool[] + prompts: Array<{ name: string; description?: string }> + resources: Array<{ name: string; uri: string; description?: string }> + resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }> + toolPages?: Record> + promptPages?: Record> + resourcePages?: Record> + resourceTemplatePages?: Record> + listToolsError?: string + requestDelay?: number + roots?: Array<{ uri: string; name?: string }> + requests: string[] + aborted: number +} + +function lifecycleServer(input?: { capabilities?: ServerCapabilities; instructions?: string; requestRoots?: boolean }) { + const capabilities = input?.capabilities ?? { tools: {}, prompts: {}, resources: {} } + return Effect.acquireRelease( + Effect.promise(async () => { + const state: LifecycleServerState = { + tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }], + prompts: [], + resources: [], + resourceTemplates: [], + requests: [], + aborted: 0, + } + + const makeProtocol = async () => { + const protocol = new Server( + { name: "mcp-lifecycle", version: "1.0.0" }, + { capabilities, instructions: input?.instructions }, + ) + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + }) + + if (capabilities.tools) { + protocol.setRequestHandler(ListToolsRequestSchema, (request) => { + if (state.listToolsError) throw new Error(state.listToolsError) + const page = state.toolPages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ tools: page?.items ?? state.tools, nextCursor: page?.nextCursor }) + }) + } + if (capabilities.prompts) { + protocol.setRequestHandler(ListPromptsRequestSchema, (request) => { + const page = state.promptPages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ prompts: page?.items ?? state.prompts, nextCursor: page?.nextCursor }) + }) + protocol.setRequestHandler(GetPromptRequestSchema, async () => { + if (state.requestDelay) await Bun.sleep(state.requestDelay) + return { messages: [{ role: "user", content: { type: "text", text: "prompt result" } }] } + }) + } + if (capabilities.resources) { + protocol.setRequestHandler(ListResourcesRequestSchema, (request) => { + const page = state.resourcePages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor }) + }) + protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => { + const page = state.resourceTemplatePages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ + resourceTemplates: page?.items ?? state.resourceTemplates, + nextCursor: page?.nextCursor, + }) + }) + protocol.setRequestHandler(ReadResourceRequestSchema, async (request) => { + if (state.requestDelay) await Bun.sleep(state.requestDelay) + return { contents: [{ uri: request.params.uri, text: "resource result" }] } + }) + } + + protocol.oninitialized = () => { + if (!input?.requestRoots) return + if (!protocol.getClientCapabilities()?.roots) return + void Bun.sleep(25) + .then(() => protocol.listRoots()) + .then((result) => { + state.roots = result.roots + }) + .catch(() => {}) + } + await protocol.connect(transport) + return { protocol, transport } + } + + let current = await makeProtocol() + const http = Bun.serve({ + port: 0, + fetch(request) { + state.requests.push(request.method) + request.signal.addEventListener("abort", () => state.aborted++) + return current.transport.handleRequest(request) + }, + }) + + return { + state, + url: http.url.toString(), + sendToolListChanged: () => current.protocol.sendToolListChanged(), + restart: async () => { + current = await makeProtocol() + }, + close: async () => { + await current.protocol.close().catch(() => {}) + http.stop(true) + }, + } + }), + (server) => Effect.promise(server.close), + ) +} + +function hangingLifecycleServer() { + return Effect.acquireRelease( + Effect.promise(async () => { + const protocol = new Server({ name: "mcp-lifecycle-hanging", version: "1.0.0" }, { capabilities: { tools: {} } }) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + }) + await protocol.connect(transport) + const requests: string[] = [] + let aborted = 0 + const http = Bun.serve({ + port: 0, + fetch(request) { + requests.push(request.method) + request.signal.addEventListener("abort", () => aborted++) + return new Promise(() => {}) + }, + }) + return { + requests, + aborted: () => aborted, + url: http.url.toString(), + close: async () => { + await protocol.close().catch(() => {}) + http.stop(true) + }, + } + }), + (server) => Effect.promise(server.close), + ) +} + +function statusName(status: Record | MCPNS.Status, server: string) { + if ("status" in status) return status.status + return status[server]?.status +} + +const remote = (url: string, timeout?: number) => ({ type: "remote" as const, url, oauth: false as const, timeout }) + +it.instance("advertises and lists the instance directory as its root", () => + Effect.gen(function* () { + const server = yield* lifecycleServer({ requestRoots: true }) + const mcp = yield* MCP.Service + const test = yield* TestInstance + yield* mcp.add("roots", remote(server.url)) + + const roots = yield* pollWithTimeout( + Effect.sync(() => server.state.roots), + "server did not receive roots", + ) + expect(roots).toEqual([{ uri: pathToFileURL(test.directory).href }]) + }), +) + +it.instance( + "local mcp cwd resolves relative paths against instance directory", + () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + const test = yield* TestInstance + yield* mcp.add("rel-cwd", { + type: "local", + command: [process.execPath, stdioFixture], + cwd: "plugins/sub", + }) + + expect((yield* mcp.tools())["rel-cwd_current_directory"]?.def.description).toBe( + path.resolve(test.directory, "plugins/sub"), + ) + }), + { init: (directory) => Effect.promise(() => Bun.$`mkdir -p ${path.join(directory, "plugins/sub")}`.quiet()) }, +) + +it.instance("tools() reuses cached definitions until a protocol notification", () => + Effect.gen(function* () { + const server = yield* lifecycleServer({ capabilities: { tools: { listChanged: true } } }) + const mcp = yield* MCP.Service + yield* mcp.add("cache-server", remote(server.url)) + server.state.tools = [{ name: "next_tool", inputSchema: { type: "object", properties: {} } }] + + expect(Object.keys(yield* mcp.tools())).toEqual(["cache-server_test_tool"]) + yield* Effect.promise(server.sendToolListChanged) + yield* pollWithTimeout( + Effect.gen(function* () { + const keys = Object.keys(yield* mcp.tools()) + return keys.includes("cache-server_next_tool") ? keys : undefined + }), + "tool cache did not refresh", + ) + expect(Object.keys(yield* mcp.tools())).toEqual(["cache-server_next_tool"]) + }), +) + +it.instance("instructions() returns non-empty connected server instructions with tool names", () => + Effect.gen(function* () { + const guide = yield* lifecycleServer({ instructions: "Use lookup before mutate." }) + const blank = yield* lifecycleServer({ instructions: " " }) + const mcp = yield* MCP.Service + yield* mcp.add("guide-server", remote(guide.url)) + yield* mcp.add("blank-server", remote(blank.url)) + + expect(yield* mcp.instructions()).toEqual([ + { name: "guide-server", instructions: "Use lookup before mutate.", tools: ["guide-server_test_tool"] }, + ]) + yield* mcp.disconnect("guide-server") + expect(yield* mcp.instructions()).toEqual([]) + }), +) + +it.instance("follows cursors when listing tools, prompts, resources, and templates", () => + Effect.gen(function* () { + const server = yield* lifecycleServer() + server.state.toolPages = { + initial: { items: [{ name: "tool-one", inputSchema: { type: "object" } }], nextCursor: "tools-2" }, + "tools-2": { items: [{ name: "tool-two", inputSchema: { type: "object" } }] }, + } + server.state.promptPages = { + initial: { items: [{ name: "prompt-one" }], nextCursor: "prompts-2" }, + "prompts-2": { items: [{ name: "prompt-two" }] }, + } + server.state.resourcePages = { + initial: { items: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" }, + "resources-2": { items: [{ name: "resource-two", uri: "test://two" }] }, + } + server.state.resourceTemplatePages = { + initial: { items: [{ name: "template-one", uriTemplate: "test://one/{id}" }], nextCursor: "templates-2" }, + "templates-2": { items: [{ name: "template-two", uriTemplate: "test://two/{id}" }] }, + } + const mcp = yield* MCP.Service + yield* mcp.add("paged-server", remote(server.url)) + + expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"]) + expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"]) + expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([ + "paged-server:test://one/{id}", + "paged-server:test://two/{id}", + ]) + }), +) + +it.instance("accepts empty cursors and rejects repeated cursors", () => + Effect.gen(function* () { + const empty = yield* lifecycleServer({ capabilities: { prompts: {} } }) + empty.state.promptPages = { + initial: { items: [{ name: "prompt-one" }], nextCursor: "" }, + "": { items: [{ name: "prompt-two" }] }, + } + const looping = yield* lifecycleServer({ capabilities: { tools: {} } }) + looping.state.toolPages = { + initial: { items: [], nextCursor: "repeat" }, + repeat: { items: [], nextCursor: "repeat" }, + } + const mcp = yield* MCP.Service + yield* mcp.add("empty-cursor", remote(empty.url)) + const result = yield* mcp.add("looping-cursor", remote(looping.url)) + + expect(Object.keys(yield* mcp.prompts())).toEqual(["empty-cursor:prompt-one", "empty-cursor:prompt-two"]) + expect(statusName(result.status, "looping-cursor")).toBe("failed") + }), +) + +it.instance("disconnect removes protocol data and reconnect establishes a new session", () => + Effect.gen(function* () { + const server = yield* lifecycleServer() + const mcp = yield* MCP.Service + yield* mcp.add("reconnect-server", remote(server.url)) + expect((yield* mcp.status())["reconnect-server"]?.status).toBe("connected") + + yield* mcp.disconnect("reconnect-server") + expect((yield* mcp.status())["reconnect-server"]?.status).toBe("disabled") + expect(Object.keys(yield* mcp.tools())).toEqual([]) + yield* pollWithTimeout( + Effect.sync(() => (server.state.aborted > 0 ? server.state.aborted : undefined)), + "disconnected HTTP session was not aborted", + ) + + yield* Effect.promise(server.restart) + yield* mcp.connect("reconnect-server") + expect((yield* mcp.status())["reconnect-server"]?.status).toBe("connected") + expect(Object.keys(yield* mcp.tools())).toEqual(["reconnect-server_test_tool"]) + }), +) + +it.instance("add() closes the old protocol session when replacing a server", () => + Effect.gen(function* () { + const first = yield* lifecycleServer() + const second = yield* lifecycleServer() + const mcp = yield* MCP.Service + yield* mcp.add("replace-server", remote(first.url)) + yield* mcp.add("replace-server", remote(second.url)) + + yield* pollWithTimeout( + Effect.sync(() => (first.state.aborted > 0 ? first.state.aborted : undefined)), + "replaced HTTP session was not aborted", + ) + expect(second.state.aborted).toBe(0) + expect(Object.keys(yield* mcp.tools())).toEqual(["replace-server_test_tool"]) + }), +) + +it.instance("one failed server does not affect another connected server", () => + Effect.gen(function* () { + const good = yield* lifecycleServer() + good.state.tools = [{ name: "good_tool", inputSchema: { type: "object" } }] + const bad = yield* lifecycleServer() + bad.state.listToolsError = "listTools failed" + const mcp = yield* MCP.Service + yield* mcp.add("good-server", remote(good.url)) + yield* mcp.add("bad-server", remote(bad.url)) + + expect((yield* mcp.status())["good-server"]?.status).toBe("connected") + expect((yield* mcp.status())["bad-server"]?.status).toBe("failed") + expect(Object.keys(yield* mcp.tools())).toEqual(["good-server_good_tool"]) + }), +) + +it.instance("falls back when output schema refs fail SDK tool discovery", () => + Effect.gen(function* () { + const server = yield* lifecycleServer({ capabilities: { tools: {} } }) + server.state.tools = [ + { + name: "render_screen", + inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }, + outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } }, + }, + ] + const mcp = yield* MCP.Service + const result = yield* mcp.add("schema-server", remote(server.url)) + + expect(statusName(result.status, "schema-server")).toBe("connected") + expect(Object.keys(yield* mcp.tools())).toEqual(["schema-server_render_screen"]) + }), +) + +it.instance("does not fall back for protocol tool discovery errors", () => + Effect.gen(function* () { + const server = yield* lifecycleServer({ capabilities: { tools: {} } }) + server.state.listToolsError = "transport closed" + const mcp = yield* MCP.Service + const result = yield* mcp.add("broken-server", remote(server.url)) + + expect(statusName(result.status, "broken-server")).toBe("failed") + }), +) + +it.instance("disabled server is marked disabled without opening a protocol session", () => + Effect.gen(function* () { + const server = yield* lifecycleServer() + const mcp = yield* MCP.Service + yield* mcp.add("disabled-server", { ...remote(server.url), enabled: false }) + + expect((yield* mcp.status())["disabled-server"]?.status).toBe("disabled") + expect(server.state.requests).toEqual([]) + }), +) + +it.instance("returns prompts and URI-keyed resources from connected servers", () => + Effect.gen(function* () { + const server = yield* lifecycleServer() + server.state.prompts = [{ name: "my-prompt", description: "A test prompt" }] + server.state.resources = [ + { name: "same-name", uri: "file:///test.txt" }, + { name: "same-name", uri: "ui://component-state" }, + ] + const mcp = yield* MCP.Service + yield* mcp.add("content-server", remote(server.url)) + + expect(Object.keys(yield* mcp.prompts())).toEqual(["content-server:my-prompt"]) + expect(Object.keys(yield* mcp.resources())).toEqual([ + "content-server:file:///test.txt", + "content-server:ui://component-state", + ]) + yield* mcp.disconnect("content-server") + expect(yield* mcp.prompts()).toEqual({}) + }), +) + +it.instance("uses per-server timeouts for prompt and resource requests", () => + Effect.gen(function* () { + const server = yield* lifecycleServer() + server.state.requestDelay = 200 + const mcp = yield* MCP.Service + yield* mcp.add("timeout-server", remote(server.url, 50)) + + expect(yield* mcp.getPrompt("timeout-server", "test")).toBeUndefined() + expect(yield* mcp.readResource("timeout-server", "test://resource")).toBeUndefined() + }), +) + +it.instance("connects resource-only, prompt-only, and tools-only servers", () => + Effect.gen(function* () { + const resources = yield* lifecycleServer({ capabilities: { resources: {} } }) + resources.state.resources = [{ name: "docs", uri: "docs://readme" }] + const prompts = yield* lifecycleServer({ capabilities: { prompts: {} } }) + prompts.state.prompts = [{ name: "review" }] + const tools = yield* lifecycleServer({ capabilities: { tools: {} } }) + const mcp = yield* MCP.Service + yield* mcp.add("resource-only", remote(resources.url)) + yield* mcp.add("prompt-only", remote(prompts.url)) + yield* mcp.add("tools-only", remote(tools.url)) + + expect(Object.keys(yield* mcp.tools())).toEqual(["tools-only_test_tool"]) + expect(Object.keys(yield* mcp.prompts())).toEqual(["prompt-only:review"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only:docs://readme"]) + }), +) + +it.instance("connect and disconnect fail for unknown servers", () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + for (const operation of [mcp.connect("missing"), mcp.disconnect("missing")]) { + const exit = yield* operation.pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "MCP.NotFoundError", name: "missing" }) + } + } + expect(yield* mcp.status()).toEqual({}) + expect(yield* mcp.tools()).toEqual({}) + }), +) + +it.instance("unavailable remote server is marked failed without tools", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.sync(() => Bun.serve({ port: 0, fetch: () => new Response("unavailable", { status: 503 }) })), + (http) => Effect.promise(() => http.stop(true)), + ) + const mcp = yield* MCP.Service + yield* mcp.add("unavailable", remote(server.url.toString(), 500)) + + expect((yield* mcp.status()).unavailable?.status).toBe("failed") + expect(yield* mcp.tools()).toEqual({}) + }), +) + +it.instance("tools() prefixes sanitized server and tool names", () => + Effect.gen(function* () { + const server = yield* lifecycleServer({ capabilities: { tools: {} } }) + server.state.tools = [ + { name: "tool-a", inputSchema: { type: "object" } }, + { name: "tool.b", inputSchema: { type: "object" } }, + ] + const mcp = yield* MCP.Service + yield* mcp.add("my.special-server", remote(server.url)) + + expect(Object.keys(yield* mcp.tools())).toEqual(["my_special-server_tool-a", "my_special-server_tool_b"]) + }), +) + +it.instance("local stdio timeout terminates the real server process", () => + Effect.gen(function* () { + const test = yield* TestInstance + const pidFile = path.join(test.directory, "mcp.pid") + const mcp = yield* MCP.Service + const result = yield* mcp.add("hanging-stdio", { + type: "local", + command: [process.execPath, stdioFixture, "--hang"], + environment: { MCP_LIFECYCLE_PID_FILE: pidFile }, + timeout: 100, + }) + + expect(statusName(result.status, "hanging-stdio")).toBe("failed") + const pid = yield* pollWithTimeout( + Effect.promise(async () => { + const file = Bun.file(pidFile) + return (await file.exists()) ? Number(await file.text()) : undefined + }), + "stdio fixture did not publish its pid", + ) + yield* pollWithTimeout( + Effect.sync(() => { + try { + process.kill(pid, 0) + return undefined + } catch { + return true + } + }), + "stdio fixture process was not terminated", + ) + }), +) + +it.instance("remote timeout aborts both real HTTP transport attempts", () => + Effect.gen(function* () { + const server = yield* hangingLifecycleServer() + const mcp = yield* MCP.Service + const result = yield* mcp.add("hanging-remote", remote(server.url, 100)) + + expect(statusName(result.status, "hanging-remote")).toBe("failed") + yield* pollWithTimeout( + Effect.sync(() => (server.aborted() >= 2 ? server.aborted() : undefined)), + "remote transport requests were not aborted", + ) + expect(server.requests).toEqual(["POST", "GET"]) + }), +) + +it.live("McpOAuthCallback.cancelPending rejects the pending callback", () => + Effect.acquireUseRelease( + Effect.sync(() => McpOAuthCallback.waitForCallback("abc123hexstate", "my-mcp-server")), + (callback) => + Effect.gen(function* () { + McpOAuthCallback.cancelPending("my-mcp-server") + const exit = yield* Effect.tryPromise({ + try: () => callback, + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + }), + () => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore), + ), +) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f8889068c338596c419006851ddff21701f05a0 --- /dev/null +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -0,0 +1,300 @@ +import { expect } from "bun:test" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { ListResourcesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect } from "effect" +import { Config } from "../../src/config/config" +import { EventV2Bridge } from "../../src/event-v2-bridge" +import { McpAuth } from "../../src/mcp/auth" +import { MCP } from "../../src/mcp/index" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" +import { McpOAuthPendingProvider, McpOAuthProvider } from "../../src/mcp/oauth-provider" +import { testEffect } from "../lib/effect" + +const mcpTest = testEffect( + LayerNode.compile( + LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]), + ), +) + +interface OAuthMcpOptions { + capabilities?: "tools" | "resources" +} + +function serveOAuthMcp(options: OAuthMcpOptions = {}) { + return Effect.acquireRelease( + Effect.promise(async () => { + const capabilities = options.capabilities ?? "tools" + const protocol = new Server( + { name: "oauth-auto-connect", version: "1.0.0" }, + { capabilities: capabilities === "tools" ? { tools: {} } : { resources: {} } }, + ) + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + }) + let listToolsCalls = 0 + let requiresAuth = true + + if (capabilities === "tools") { + protocol.setRequestHandler(ListToolsRequestSchema, () => { + listToolsCalls++ + return Promise.resolve({ tools: [{ name: "test_tool", inputSchema: { type: "object" } }] }) + }) + } + if (capabilities === "resources") { + protocol.setRequestHandler(ListResourcesRequestSchema, () => + Promise.resolve({ resources: [{ name: "docs", uri: "docs://readme" }] }), + ) + } + + await protocol.connect(transport) + const http = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url) + const origin = url.origin + const mcpUrl = `${origin}/mcp` + + if (url.pathname === "/.well-known/oauth-protected-resource/mcp") { + return Response.json({ + resource: mcpUrl, + authorization_servers: [origin], + scopes_supported: ["mcp"], + }) + } + if (url.pathname === "/.well-known/oauth-protected-resource") { + return Response.json({ + resource: mcpUrl, + authorization_servers: [origin], + scopes_supported: ["mcp"], + }) + } + if (url.pathname === "/.well-known/oauth-authorization-server") { + return Response.json({ + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + registration_endpoint: `${origin}/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: ["none"], + code_challenge_methods_supported: ["S256"], + scopes_supported: ["mcp"], + }) + } + if (url.pathname === "/register") { + const metadata = (await request.json()) as Record + return Response.json({ ...metadata, client_id: "replacement-client" }, { status: 201 }) + } + if (url.pathname === "/token") { + const body = new URLSearchParams(await request.text()) + if (body.get("code") !== "valid-code") { + return Response.json( + { error: "invalid_grant", error_description: "Token exchange failed" }, + { status: 400 }, + ) + } + return Response.json({ access_token: "replacement-token", token_type: "Bearer" }) + } + if (url.pathname !== "/mcp") return new Response("Not found", { status: 404 }) + + if (request.method === "GET") return new Response(null, { status: 405 }) + if (requiresAuth && request.headers.get("authorization") !== "Bearer replacement-token") { + return new Response("Unauthorized", { + status: 401, + headers: { + "WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource", scope="mcp"`, + }, + }) + } + return transport.handleRequest(request) + }, + }) + + return { + url: new URL("/mcp", http.url).toString(), + allowAnonymous: () => { + requiresAuth = false + }, + listToolsCalls: () => listToolsCalls, + close: async () => { + await http.stop(true) + await protocol.close() + }, + } + }), + (server) => Effect.promise(server.close), + ) +} + +const remote = (url: string, enabled = true) => ({ + type: "remote" as const, + url, + enabled, +}) + +const stopOAuthCallback = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore)) + +mcpTest.instance("first connect to OAuth server shows needs_auth instead of failed", () => + Effect.gen(function* () { + const server = yield* serveOAuthMcp() + const mcp = yield* MCP.Service + const result = yield* mcp.add("test-oauth", remote(server.url)) + + expect((result.status as Record)["test-oauth"]).toEqual({ status: "needs_auth" }) + }), +) + +mcpTest.instance("state() generates and persists a new state when none is saved", () => + Effect.gen(function* () { + const auth = yield* McpAuth.Service + const provider = new McpOAuthProvider( + "test-state-gen", + "https://example.com/mcp", + {}, + { onRedirect: async () => {} }, + auth, + ) + + expect((yield* auth.get("test-state-gen"))?.oauthState).toBeUndefined() + + const state = yield* Effect.promise(() => provider.state()) + expect(state).toHaveLength(64) + expect((yield* auth.get("test-state-gen"))?.oauthState).toBe(state) + }), +) + +mcpTest.instance("state() returns existing state when one is saved", () => + Effect.gen(function* () { + const auth = yield* McpAuth.Service + const provider = new McpOAuthProvider( + "test-state-existing", + "https://example.com/mcp", + {}, + { onRedirect: async () => {} }, + auth, + ) + + yield* auth.updateOAuthState("test-state-existing", "pre-saved-state-value") + expect(yield* Effect.promise(() => provider.state())).toBe("pre-saved-state-value") + }), +) + +mcpTest.instance("pending provider does not expose or overwrite existing credentials before commit", () => + Effect.gen(function* () { + const auth = yield* McpAuth.Service + const name = "test-pending-credentials" + const url = "https://example.com/mcp" + const provider = new McpOAuthPendingProvider(name, url, {}, { onRedirect: async () => {} }, auth) + + yield* auth.updateClientInfo(name, { clientId: "old-client" }, url) + yield* auth.updateTokens(name, { accessToken: "old-token" }, url) + + expect(yield* Effect.promise(() => provider.clientInformation())).toBeUndefined() + expect(yield* Effect.promise(() => provider.tokens())).toBeUndefined() + expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token") + expect((yield* auth.get(name))?.clientInfo?.clientId).toBe("old-client") + }), +) + +mcpTest.instance("failed reauthentication preserves existing credentials", () => + Effect.gen(function* () { + yield* stopOAuthCallback + const server = yield* serveOAuthMcp() + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + const name = "test-reauth-failure" + + yield* auth.updateClientInfo(name, { clientId: "dynamic-client", clientSecret: "dynamic-secret" }, server.url) + yield* auth.updateTokens(name, { accessToken: "working-token" }, server.url) + yield* mcp.add(name, remote(server.url)) + expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("/authorize") + + expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({ + status: "failed", + error: "OAuth completion failed: Token exchange failed", + }) + expect((yield* auth.get(name))?.tokens?.accessToken).toBe("working-token") + expect((yield* auth.get(name))?.clientInfo).toMatchObject({ + clientId: "dynamic-client", + clientSecret: "dynamic-secret", + }) + }), +) + +mcpTest.instance("successful reauthentication commits replacement credentials", () => + Effect.gen(function* () { + yield* stopOAuthCallback + const server = yield* serveOAuthMcp() + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + const name = "test-reauth-success" + + yield* auth.updateClientInfo(name, { clientId: "old-client" }, server.url) + yield* auth.updateTokens(name, { accessToken: "old-token" }, server.url) + yield* mcp.add(name, remote(server.url)) + expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("/authorize") + expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token") + + expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected") + const entry = yield* auth.get(name) + expect(entry?.tokens?.accessToken).toBe("replacement-token") + expect(entry?.clientInfo?.clientId).toBe("replacement-client") + expect(entry?.serverUrl).toBe(server.url) + }), +) + +mcpTest.instance("auth status only reports credentials stored for the configured server URL", () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + yield* mcp.add("test-status-url", remote("https://example.com/mcp", false)) + yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "old-token" }, "https://old.example.com/mcp") + + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("not_authenticated") + + yield* McpAuth.use.updateTokens("test-status-url", { accessToken: "current-token" }, "https://example.com/mcp") + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("authenticated") + + yield* McpAuth.use.updateTokens( + "test-status-url", + { accessToken: "expired-token", expiresAt: 1 }, + "https://example.com/mcp", + ) + expect(yield* mcp.getAuthStatus("test-status-url")).toBe("expired") + }), +) + +mcpTest.instance("authenticate() stores a connected client when auth completes without redirect", () => + Effect.gen(function* () { + yield* stopOAuthCallback + const server = yield* serveOAuthMcp() + const mcp = yield* MCP.Service + const name = "test-oauth-connect" + const added = yield* mcp.add(name, remote(server.url)) + expect((added.status as Record)[name]?.status).toBe("needs_auth") + + server.allowAnonymous() + expect((yield* mcp.authenticate(name)).status).toBe("connected") + expect((yield* mcp.status())[name]?.status).toBe("connected") + }), +) + +mcpTest.instance("authenticate() connects a resource-only server without listing tools", () => + Effect.gen(function* () { + yield* stopOAuthCallback + const server = yield* serveOAuthMcp({ capabilities: "resources" }) + const mcp = yield* MCP.Service + const name = "test-oauth-resources" + const added = yield* mcp.add(name, remote(server.url)) + expect((added.status as Record)[name]?.status).toBe("needs_auth") + + server.allowAnonymous() + expect((yield* mcp.authenticate(name)).status).toBe("connected") + expect(server.listToolsCalls()).toBe(0) + expect(Object.keys(yield* mcp.resources())).toEqual([`${name}:docs://readme`]) + }), +) diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9573805a9a14d63339a9a05d643e8ec1a6fe8dd7 --- /dev/null +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -0,0 +1,225 @@ +import { expect } from "bun:test" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Deferred, Effect, Layer, Option } from "effect" +import { Config } from "../../src/config/config" +import { EventV2Bridge } from "../../src/event-v2-bridge" +import { McpAuth } from "../../src/mcp/auth" +import { McpBrowser } from "../../src/mcp/browser" +import { MCP } from "../../src/mcp/index" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" +import { awaitWithTimeout, testEffect } from "../lib/effect" + +const browsers = new Map; fail: boolean }>() + +const browserLayer = Layer.succeed( + McpBrowser.Service, + McpBrowser.Service.of({ + open: (url) => + Effect.gen(function* () { + const browser = browsers.get(new URL(url).origin) + if (!browser) return yield* Effect.fail(new Error(`Unexpected browser URL: ${url}`)) + Deferred.doneUnsafe(browser.opened, Effect.succeed(url)) + if (browser.fail) return yield* Effect.fail(new Error("spawn xdg-open ENOENT")) + yield* Effect.tryPromise({ + try: () => fetch(url).then((response) => response.body?.cancel()), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) + }), + }), +) + +const mcpTest = testEffect( + LayerNode.compile(LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node]), [ + [McpBrowser.node, browserLayer], + ]), +) + +const serveOAuthMcp = Effect.acquireRelease( + Effect.promise(async () => { + const requests: Array<{ pathname: string; headers: Headers }> = [] + const protocol = new Server({ name: "oauth-browser", version: "1.0.0" }, { capabilities: { tools: {} } }) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + }) + await protocol.connect(transport) + + const http = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const url = new URL(request.url) + requests.push({ pathname: url.pathname, headers: new Headers(request.headers) }) + + if (url.pathname === "/mcp") { + if (request.headers.get("authorization") === "Bearer test-access-token") { + return transport.handleRequest(request) + } + return new Response("Unauthorized", { + status: 401, + headers: { + "WWW-Authenticate": `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource/mcp", scope="mcp"`, + }, + }) + } + + if (url.pathname === "/.well-known/oauth-protected-resource/mcp") { + return Response.json({ + resource: `${url.origin}/mcp`, + authorization_servers: [url.origin], + scopes_supported: ["mcp"], + }) + } + + if (url.pathname === "/.well-known/oauth-authorization-server") { + return Response.json({ + issuer: url.origin, + authorization_endpoint: `${url.origin}/authorize`, + token_endpoint: `${url.origin}/token`, + registration_endpoint: `${url.origin}/register`, + scopes_supported: ["mcp"], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + token_endpoint_auth_methods_supported: ["none"], + code_challenge_methods_supported: ["S256"], + }) + } + + if (url.pathname === "/register") { + const metadata = await request.json() + if (!metadata || typeof metadata !== "object") return new Response("Invalid metadata", { status: 400 }) + return Response.json({ ...metadata, client_id: "test-client" }, { status: 201 }) + } + + if (url.pathname === "/authorize") { + const redirect = new URL(url.searchParams.get("redirect_uri") ?? "") + redirect.searchParams.set("code", "test-code") + const state = url.searchParams.get("state") + if (state) redirect.searchParams.set("state", state) + return Response.redirect(redirect, 302) + } + + if (url.pathname === "/token") { + return Response.json({ access_token: "test-access-token", token_type: "Bearer", scope: "mcp" }) + } + + return new Response("Not found", { status: 404 }) + }, + }) + + return { + requests, + url: new URL("/mcp", http.url).toString(), + close: async () => { + await http.stop(true) + await protocol.close() + }, + } + }), + (server) => Effect.promise(server.close), +) + +const withCallbackStop = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore)) + +const trackBrowserOpen = (url: string, fail = false) => + Effect.gen(function* () { + const origin = new URL(url).origin + const opened = yield* Deferred.make() + browsers.set(origin, { opened, fail }) + yield* Effect.addFinalizer(() => Effect.sync(() => browsers.delete(origin))) + return opened + }) + +const trackBrowserOpenFailed = Effect.gen(function* () { + const events = yield* EventV2Bridge.Service + const event = yield* Deferred.make<{ mcpName: string; url: string }>() + const unsubscribe = yield* events.listen((evt) => { + if (evt.type === MCP.BrowserOpenFailed.type) + Deferred.doneUnsafe(event, Effect.succeed(evt.data as { mcpName: string; url: string })) + return Effect.void + }) + yield* Effect.addFinalizer(() => unsubscribe) + return event +}) + +const addServer = Effect.fnUntraced(function* (name: string, url: string, headers?: Record) { + const mcp = yield* MCP.Service + const result = yield* mcp.add(name, { type: "remote", url, headers }) + expect(result.status).toMatchObject({ [name]: { status: "needs_auth" } }) + return mcp +}) + +mcpTest.instance("BrowserOpenFailed event is published when browser launch fails", () => + Effect.gen(function* () { + yield* withCallbackStop + const server = yield* serveOAuthMcp + yield* trackBrowserOpen(server.url, true) + + const event = yield* trackBrowserOpenFailed + const mcp = yield* addServer("test-oauth-server", server.url) + yield* mcp.authenticate("test-oauth-server").pipe(Effect.ignore, Effect.forkScoped) + + const failure = yield* awaitWithTimeout( + Deferred.await(event), + "Timed out waiting for BrowserOpenFailed event", + "5 seconds", + ) + + expect(failure.mcpName).toBe("test-oauth-server") + expect(failure.url).toStartWith(new URL("/authorize", server.url).toString()) + }), +) + +mcpTest.instance("BrowserOpenFailed event is not published when browser launch succeeds", () => + Effect.gen(function* () { + yield* withCallbackStop + const server = yield* serveOAuthMcp + + const opened = yield* trackBrowserOpen(server.url) + const event = yield* trackBrowserOpenFailed + const mcp = yield* addServer("test-oauth-server-2", server.url) + const status = yield* awaitWithTimeout( + mcp.authenticate("test-oauth-server-2"), + "Timed out completing OAuth authentication", + "5 seconds", + ) + const url = yield* Deferred.await(opened) + const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis")) + + expect(status).toEqual({ status: "connected" }) + expect(failure).toEqual(Option.none()) + expect(new URL(url).origin).toBe(new URL(server.url).origin) + }), +) + +mcpTest.instance("browser launch receives the discovered authorization URL", () => + Effect.gen(function* () { + yield* withCallbackStop + const server = yield* serveOAuthMcp + + const opened = yield* trackBrowserOpen(server.url) + const authorization = yield* Deferred.make() + const mcp = yield* addServer("test-oauth-server-3", server.url, { "X-Custom-Header": "custom-value" }) + const status = yield* awaitWithTimeout( + mcp.authenticate("test-oauth-server-3", (url) => Deferred.doneUnsafe(authorization, Effect.succeed(url))), + "Timed out completing OAuth authentication", + "5 seconds", + ) + const url = yield* Deferred.await(opened) + const authorizationUrl = yield* Deferred.await(authorization) + + expect(status).toEqual({ status: "connected" }) + expect(authorizationUrl).toBe(url) + expect(new URL(url).pathname).toBe("/authorize") + expect(new URL(url).searchParams.get("client_id")).toBe("test-client") + expect( + server.requests.some( + (request) => request.pathname === "/mcp" && request.headers.get("x-custom-header") === "custom-value", + ), + ).toBe(true) + }), +) diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1666a37142b0bbae820f4165030cbf69d342394e --- /dev/null +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -0,0 +1,114 @@ +import { test, expect, describe, afterEach } from "bun:test" +import { createConnection, createServer as createNetServer } from "net" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" +import { parseRedirectUri } from "../../src/mcp/oauth-provider" + +async function getFreeLoopbackPort(): Promise { + return new Promise((resolve, reject) => { + const probe = createNetServer() + probe.once("error", reject) + probe.listen(0, "127.0.0.1", () => { + const address = probe.address() + probe.close(() => { + if (typeof address === "object" && address) { + resolve(address.port) + return + } + reject(new Error("Could not allocate a loopback port")) + }) + }) + }) +} + +async function canConnect(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = createConnection({ host, port }) + const done = (ok: boolean) => { + socket.removeAllListeners() + socket.destroy() + resolve(ok) + } + + socket.setTimeout(500) + socket.once("connect", () => done(true)) + socket.once("error", () => done(false)) + socket.once("timeout", () => done(false)) + }) +} + +describe("parseRedirectUri", () => { + test("returns defaults when no URI provided", () => { + const result = parseRedirectUri() + expect(result.port).toBe(19876) + expect(result.path).toBe("/mcp/oauth/callback") + }) + + test("parses port and path from URI", () => { + const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback") + expect(result.port).toBe(8080) + expect(result.path).toBe("/oauth/callback") + }) + + test("returns defaults for invalid URI", () => { + const result = parseRedirectUri("not-a-valid-url") + expect(result.port).toBe(19876) + expect(result.path).toBe("/mcp/oauth/callback") + }) +}) + +describe("McpOAuthCallback.ensureRunning", () => { + afterEach(async () => { + await McpOAuthCallback.stop() + }) + + test("starts server with custom redirectUri port and path", async () => { + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback") + expect(McpOAuthCallback.isRunning()).toBe(true) + }) + + test("stops after the callback completes", async () => { + const redirectUri = "http://127.0.0.1:18003/custom/callback" + await McpOAuthCallback.ensureRunning(redirectUri) + const callback = McpOAuthCallback.waitForCallback("success") + + const response = await fetch(`${redirectUri}?code=code&state=success`) + + expect(response.status).toBe(200) + expect(await callback).toBe("code") + expect(McpOAuthCallback.isRunning()).toBe(false) + }) + + test("escapes provider error markup in callback HTML", async () => { + const redirectUri = "http://127.0.0.1:18001/custom/callback" + await McpOAuthCallback.ensureRunning(redirectUri) + + const error = `` + const response = await fetch( + `${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent(error)}`, + ) + const body = await response.text() + + expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8") + expect(body).toContain("<script>alert("xss" & 'more')</script>") + expect(body).not.toContain(error) + }) + + test("keeps normal provider errors readable", async () => { + const redirectUri = "http://127.0.0.1:18002/custom/callback" + await McpOAuthCallback.ensureRunning(redirectUri) + + const response = await fetch( + `${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`, + ) + + expect(await response.text()).toContain('

The user denied access
') + }) + + test("binds the callback server to IPv4 loopback", async () => { + const port = await getFreeLoopbackPort() + await McpOAuthCallback.ensureRunning(`http://127.0.0.1:${port}/custom/callback`) + + expect(await canConnect("127.0.0.1", port)).toBe(true) + expect(await canConnect("::1", port)).toBe(false) + }) +}) diff --git a/packages/opencode/test/mcp/oauth-provider.test.ts b/packages/opencode/test/mcp/oauth-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..249c49e8f91d9eb999228516882b8edffaec55f5 --- /dev/null +++ b/packages/opencode/test/mcp/oauth-provider.test.ts @@ -0,0 +1,102 @@ +import { test, expect, describe } from "bun:test" +import { determineScope } from "@modelcontextprotocol/sdk/client/auth.js" +import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider" +import type { McpAuth } from "../../src/mcp/auth" + +// Stub auth — only synchronous getters are exercised in these tests +const stubAuth = {} as McpAuth.Interface + +const makeProvider = (config: ConstructorParameters[2]) => + new McpOAuthProvider("test-server", "https://mcp.example.com/mcp", config, { onRedirect: async () => {} }, stubAuth) + +describe("McpOAuthProvider.redirectUrl", () => { + test("defaults to 127.0.0.1:19876/mcp/oauth/callback", () => { + const provider = makeProvider({}) + expect(provider.redirectUrl).toBe(`http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`) + }) + + test("uses callbackPort when set", () => { + const provider = makeProvider({ callbackPort: 6620 }) + expect(provider.redirectUrl).toBe(`http://127.0.0.1:6620${OAUTH_CALLBACK_PATH}`) + }) + + test("redirectUri takes precedence over callbackPort", () => { + const provider = makeProvider({ + callbackPort: 6620, + redirectUri: "http://127.0.0.1:9999/custom/callback", + }) + expect(provider.redirectUrl).toBe("http://127.0.0.1:9999/custom/callback") + }) + + test("uses explicit redirectUri when set without callbackPort", () => { + const provider = makeProvider({ redirectUri: "http://127.0.0.1:8080/oauth/callback" }) + expect(provider.redirectUrl).toBe("http://127.0.0.1:8080/oauth/callback") + }) +}) + +describe("McpOAuthProvider.clientMetadata", () => { + test("includes redirect_uris from redirectUrl", () => { + const provider = makeProvider({ callbackPort: 6620 }) + expect(provider.clientMetadata.redirect_uris).toEqual([`http://127.0.0.1:6620${OAUTH_CALLBACK_PATH}`]) + }) + + test("includes scope when set in config", () => { + const provider = makeProvider({ scope: "openid offline_access" }) + expect(provider.clientMetadata.scope).toBe("openid offline_access") + }) + + test("omits scope when not set in config", () => { + const provider = makeProvider({}) + expect(provider.clientMetadata.scope).toBeUndefined() + }) + + test("sets token_endpoint_auth_method to client_secret_post when clientSecret provided", () => { + const provider = makeProvider({ clientSecret: "secret" }) + expect(provider.clientMetadata.token_endpoint_auth_method).toBe("client_secret_post") + }) + + test("sets token_endpoint_auth_method to none when no clientSecret", () => { + const provider = makeProvider({}) + expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none") + }) +}) + +describe("MCP OAuth scope selection", () => { + test("adds offline_access when the authorization server and client support refresh tokens", () => { + expect( + determineScope({ + resourceMetadata: { + resource: "https://mcp.example.com/mcp", + scopes_supported: ["resource.read"], + }, + authServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + scopes_supported: ["resource.read", "offline_access"], + }, + clientMetadata: makeProvider({}).clientMetadata, + }), + ).toBe("resource.read offline_access") + }) + + test("does not add unsupported authorization server scopes", () => { + expect( + determineScope({ + resourceMetadata: { + resource: "https://mcp.example.com/mcp", + scopes_supported: ["resource.read"], + }, + authServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + scopes_supported: ["resource.read"], + }, + clientMetadata: makeProvider({}).clientMetadata, + }), + ).toBe("resource.read") + }) +}) diff --git a/packages/opencode/test/mcp/session-recovery.test.ts b/packages/opencode/test/mcp/session-recovery.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6586508220074eb4df30b725c2208d1b10dc01ae --- /dev/null +++ b/packages/opencode/test/mcp/session-recovery.test.ts @@ -0,0 +1,27 @@ +import path from "node:path" +import { describe, expect, test } from "bun:test" + +describe("mcp session recovery", () => { + test("reinitializes and retries once after a session-bound POST returns 404", async () => { + const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-session-recovery.ts")], { + cwd: path.join(import.meta.dir, "../.."), + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + child.exited, + Bun.readableStreamToText(child.stdout), + Bun.readableStreamToText(child.stderr), + ]) + + expect(code, stderr).toBe(0) + expect(JSON.parse(stdout)).toEqual([ + { method: "initialize", session: null }, + { method: "notifications/initialized", session: "expired" }, + { method: "ping", session: "expired" }, + { method: "initialize", session: null }, + { method: "notifications/initialized", session: "replacement" }, + { method: "ping", session: "replacement" }, + ]) + }) +}) diff --git a/packages/opencode/test/mcp/transport.test.ts b/packages/opencode/test/mcp/transport.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7a74d4d7299ec4256843e4be46b30ac04be47bb --- /dev/null +++ b/packages/opencode/test/mcp/transport.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" + +test("does not reconnect an SSE stream after a JSON-RPC error response", async () => { + let requests = 0 + const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), { + fetch: async () => { + requests += 1 + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n")) + controller.enqueue( + new TextEncoder().encode( + 'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n', + ), + ) + controller.close() + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ) + }, + reconnectionOptions: { + initialReconnectionDelay: 1, + maxReconnectionDelay: 1, + reconnectionDelayGrowFactor: 1, + maxRetries: 2, + }, + }) + + await transport.start() + await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + await Bun.sleep(25) + await transport.close() + + expect(requests).toBe(1) +}) diff --git a/packages/opencode/test/patched-dependencies.test.ts b/packages/opencode/test/patched-dependencies.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5405401bbe6428bd6a18bba9f5aa0bd911f9cf34 --- /dev/null +++ b/packages/opencode/test/patched-dependencies.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import path from "path" + +// Bun applies a patch only to the exact `name@version` named in +// `patchedDependencies`. Bumping the dependency without regenerating the patch +// does not fail `bun install`; the patch just stops applying and the runtime +// silently loses whatever the patch fixed. This pins the two together for the +// packages that ship in the CLI. +const root = path.resolve(import.meta.dir, "../../..") +const workspaces = ["packages/opencode", "packages/core"] +const patched = (await Bun.file(path.join(root, "package.json")).json()).patchedDependencies as Record + +describe("patched dependencies", () => { + for (const key of Object.keys(patched)) { + const at = key.lastIndexOf("@") + const name = key.slice(0, at) + const version = key.slice(at + 1) + + test(`${key} matches the installed version`, async () => { + expect(await Bun.file(path.join(root, patched[key])).exists()).toBe(true) + for (const workspace of workspaces) { + const file = Bun.file(path.join(root, workspace, "node_modules", name, "package.json")) + if (!(await file.exists())) continue + const installed = (await file.json()).version as string + expect(installed, `${workspace} resolves ${name}@${installed}; patch is for ${version}`).toBe(version) + } + }) + } +}) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..09a71465d5c73ad5402f181bfd0c43593d2bc484 --- /dev/null +++ b/packages/opencode/test/permission-task.test.ts @@ -0,0 +1,319 @@ +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { describe, test, expect } from "bun:test" +import { Effect } from "effect" +import { Permission } from "../src/permission" +import { Config } from "@/config/config" +import { testEffect } from "./lib/effect" + +const it = testEffect(LayerNode.compile(Config.node)) + +const load = Config.use.get() + +describe("Permission.evaluate for permission.task", () => { + const createRuleset = (rules: Record): PermissionV1.Ruleset => + Object.entries(rules).map(([pattern, action]) => ({ + permission: "task", + pattern, + action, + })) + + test("returns ask when no match (default)", () => { + expect(Permission.evaluate("task", "code-reviewer", []).action).toBe("ask") + }) + + test("returns deny for explicit deny", () => { + const ruleset = createRuleset({ "code-reviewer": "deny" }) + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + }) + + test("returns allow for explicit allow", () => { + const ruleset = createRuleset({ "code-reviewer": "allow" }) + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("allow") + }) + + test("returns ask for explicit ask", () => { + const ruleset = createRuleset({ "code-reviewer": "ask" }) + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") + }) + + test("matches wildcard patterns with deny", () => { + const ruleset = createRuleset({ "orchestrator-*": "deny" }) + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") + }) + + test("matches wildcard patterns with allow", () => { + const ruleset = createRuleset({ "orchestrator-*": "allow" }) + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow") + }) + + test("matches wildcard patterns with ask", () => { + const ruleset = createRuleset({ "orchestrator-*": "ask" }) + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask") + const globalRuleset = createRuleset({ "*": "ask" }) + expect(Permission.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask") + }) + + test("later rules take precedence (last match wins)", () => { + const ruleset = createRuleset({ + "orchestrator-*": "deny", + "orchestrator-fast": "allow", + }) + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") + }) + + test("matches global wildcard", () => { + expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow") + expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny") + expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask") + }) +}) + +describe("Permission.disabled for task tool", () => { + // Note: The `disabled` function checks if a TOOL should be completely removed from the tool list. + // It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`. + // It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`. + const createRuleset = (rules: Record): PermissionV1.Ruleset => + Object.entries(rules).map(([pattern, action]) => ({ + permission: "task", + pattern, + action, + })) + + test("task tool is disabled when global deny pattern exists (even with specific allows)", () => { + // When "*": "deny" exists, the task tool is disabled because the disabled() function + // only checks for wildcard deny patterns - it doesn't consider that specific subagents might be allowed + const ruleset = createRuleset({ + "orchestrator-*": "allow", + "*": "deny", + }) + const disabled = Permission.disabled(["task", "bash", "read"], ruleset) + // The task tool IS disabled because there's a pattern: "*" with action: "deny" + expect(disabled.has("task")).toBe(true) + }) + + test("task tool is disabled when global deny pattern exists (even with ask overrides)", () => { + const ruleset = createRuleset({ + "orchestrator-*": "ask", + "*": "deny", + }) + const disabled = Permission.disabled(["task"], ruleset) + // The task tool IS disabled because there's a pattern: "*" with action: "deny" + expect(disabled.has("task")).toBe(true) + }) + + test("task tool is disabled when global deny pattern exists", () => { + const ruleset = createRuleset({ "*": "deny" }) + const disabled = Permission.disabled(["task"], ruleset) + expect(disabled.has("task")).toBe(true) + }) + + test("task tool is NOT disabled when only specific patterns are denied (no wildcard)", () => { + // The disabled() function only disables tools when pattern: "*" && action: "deny" + // Specific subagent denies don't disable the task tool - those are handled at runtime + const ruleset = createRuleset({ + "orchestrator-*": "deny", + general: "deny", + }) + const disabled = Permission.disabled(["task"], ruleset) + // The task tool is NOT disabled because no rule has pattern: "*" with action: "deny" + expect(disabled.has("task")).toBe(false) + }) + + test("task tool is enabled when no task rules exist (default ask)", () => { + const disabled = Permission.disabled(["task"], []) + expect(disabled.has("task")).toBe(false) + }) + + test("task tool is NOT disabled when last wildcard pattern is allow", () => { + // Last matching rule wins - if wildcard allow comes after wildcard deny, tool is enabled + const ruleset = createRuleset({ + "*": "deny", + "orchestrator-coder": "allow", + }) + const disabled = Permission.disabled(["task"], ruleset) + // The disabled() function uses findLast and checks if the last matching rule + // has pattern: "*" and action: "deny". In this case, the last rule matching + // "task" permission has pattern "orchestrator-coder", not "*", so not disabled + expect(disabled.has("task")).toBe(false) + }) +}) + +// Integration tests that load permissions from real config files +describe("permission.task with real config files", () => { + it.instance( + "loads task permissions from opencode.json config", + () => + Effect.gen(function* () { + const config = yield* load + const ruleset = Permission.fromConfig(config.permission ?? {}) + // general and orchestrator-fast should be allowed, code-reviewer denied + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + }), + { + git: true, + config: { + permission: { + task: { + "*": "allow", + "code-reviewer": "deny", + }, + }, + }, + }, + ) + + it.instance( + "loads task permissions with wildcard patterns from config", + () => + Effect.gen(function* () { + const config = yield* load + const ruleset = Permission.fromConfig(config.permission ?? {}) + // general and code-reviewer should be ask, orchestrator-* denied + expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") + }), + { + git: true, + config: { + permission: { + task: { + "*": "ask", + "orchestrator-*": "deny", + }, + }, + }, + }, + ) + + it.instance( + "evaluate respects task permission from config", + () => + Effect.gen(function* () { + const config = yield* load + const ruleset = Permission.fromConfig(config.permission ?? {}) + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + // Unspecified agents default to "ask" + expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask") + }), + { + git: true, + config: { + permission: { + task: { + general: "allow", + "code-reviewer": "deny", + }, + }, + }, + }, + ) + + it.instance( + "mixed permission config with task and other tools", + () => + Effect.gen(function* () { + const config = yield* load + const ruleset = Permission.fromConfig(config.permission ?? {}) + + // Verify task permissions + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + + // Verify other tool permissions + expect(Permission.evaluate("bash", "*", ruleset).action).toBe("allow") + expect(Permission.evaluate("edit", "*", ruleset).action).toBe("ask") + + // Verify disabled tools + const disabled = Permission.disabled(["bash", "edit", "task"], ruleset) + expect(disabled.has("bash")).toBe(false) + expect(disabled.has("edit")).toBe(false) + // task is NOT disabled because disabled() uses findLast, and the last rule + // matching "task" permission is {pattern: "general", action: "allow"}, not pattern: "*" + expect(disabled.has("task")).toBe(false) + }), + { + git: true, + config: { + permission: { + bash: "allow", + edit: "ask", + task: { + "*": "deny", + general: "allow", + }, + }, + }, + }, + ) + + it.instance( + "task tool disabled when global deny comes last in config", + () => + Effect.gen(function* () { + const config = yield* load + const ruleset = Permission.fromConfig(config.permission ?? {}) + + // Last matching rule wins - "*" deny is last, so all agents are denied + expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + expect(Permission.evaluate("task", "unknown", ruleset).action).toBe("deny") + + // Since "*": "deny" is the last rule, disabled() finds it with findLast + // and sees pattern: "*" with action: "deny", so task is disabled + const disabled = Permission.disabled(["task"], ruleset) + expect(disabled.has("task")).toBe(true) + }), + { + git: true, + config: { + permission: { + task: { + general: "allow", + "code-reviewer": "allow", + "*": "deny", + }, + }, + }, + }, + ) + + it.instance( + "task tool NOT disabled when specific allow comes last in config", + () => + Effect.gen(function* () { + const config = yield* load + const ruleset = Permission.fromConfig(config.permission ?? {}) + + // Evaluate uses findLast - "general" allow comes after "*" deny + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") + // Other agents still denied by the earlier "*" deny + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + + // disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny" + // In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*" + // So the task tool is NOT disabled (even though most subagents are denied) + const disabled = Permission.disabled(["task"], ruleset) + expect(disabled.has("task")).toBe(false) + }), + { + git: true, + config: { + permission: { + task: { + "*": "deny", + general: "allow", + }, + }, + }, + }, + ) +}) diff --git a/packages/opencode/test/plugin/azure.test.ts b/packages/opencode/test/plugin/azure.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2dcdfb0399e9cb8c47b69ec45f5215c3d92bd957 --- /dev/null +++ b/packages/opencode/test/plugin/azure.test.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { chmod } from "node:fs/promises" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { tmpdir } from "../fixture/fixture" +import type { Hooks } from "@opencode-ai/plugin" +import type { Auth, Provider } from "@opencode-ai/sdk/v2" +import { OAUTH_DUMMY_KEY } from "../../src/auth" +import { AzureAuthPlugin, createAzureAuthHooks } from "../../src/plugin/azure" +import { Process } from "../../src/util/process" +import { which } from "@opencode-ai/core/util/which" + +const resourceName = process.env.AZURE_RESOURCE_NAME +const originalPath = process.env.PATH + +afterEach(() => { + if (resourceName === undefined) delete process.env.AZURE_RESOURCE_NAME + else process.env.AZURE_RESOURCE_NAME = resourceName + if (originalPath === undefined) delete process.env.PATH + else process.env.PATH = originalPath +}) + +const oauth: Auth = { + type: "oauth", + access: OAUTH_DUMMY_KEY, + refresh: OAUTH_DUMMY_KEY, + expires: Date.now() + 60 * 60 * 1000, + accountId: "test-resource", +} + +const provider: Provider = { + id: "azure", + name: "Azure", + source: "custom", + env: [], + options: {}, + models: {}, +} + +function oauthMethod(hooks: Hooks) { + const method = hooks.auth?.methods.find((method) => method.type === "oauth") + if (!method || method.type !== "oauth") throw new Error("Azure OAuth method is missing") + return method +} + +function loader(hooks: Hooks) { + if (!hooks.auth?.loader) throw new Error("Azure auth loader is missing") + return hooks.auth.loader +} + +function customFetch(options: Record) { + const result = options["fetch"] + if (typeof result !== "function") throw new Error("Azure custom fetch is missing") + return async (input: RequestInfo | URL, init?: RequestInit) => { + const response: unknown = await Reflect.apply(result, undefined, [input, init]) + if (!(response instanceof Response)) throw new Error("Azure custom fetch returned an invalid response") + return response + } +} + +function azureShell(scopes: string[]) { + return async (args: string[]) => { + const scope = args[args.indexOf("--scope") + 1] + scopes.push(scope) + return { + accessToken: `${scope}-token`, + expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000), + } + } +} + +async function azureCli(dir: string) { + const bin = path.join(dir, "azure cli") + const calls = path.join(dir, "calls.jsonl") + const script = path.join(bin, "cli.cjs") + await Bun.write(calls, "") + await Bun.write( + script, + ` + const fs = require("node:fs") + const args = process.argv.slice(2) + fs.appendFileSync(${JSON.stringify(calls)}, JSON.stringify(args) + "\\n") + console.log(JSON.stringify(args.includes("get-access-token") + ? { accessToken: "test-token", expires_on: Math.floor(Date.now() / 1000) + 3600 } + : [])) + `, + ) + const executable = path.join(bin, process.platform === "win32" ? "az.cmd" : "az") + await Bun.write( + executable, + process.platform === "win32" + ? `@"${process.execPath}" "${script}" %*\r\n` + : `#!/bin/sh\nexec '${process.execPath}' '${script}' "$@"\n`, + ) + await chmod(executable, 0o755) + return { + bin, + calls: async () => + (await Bun.file(calls).text()) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)), + } +} + +describe("plugin.azure", () => { + test("initializes and runs Azure CLI under Node without Bun or a plugin shell", async () => { + await using tmp = await tmpdir() + const node = which("node") + if (!node) throw new Error("Node is required for the Azure runtime compatibility test") + const bundle = await Bun.build({ + entrypoints: [path.join(import.meta.dir, "../../src/plugin/azure.ts")], + target: "node", + format: "esm", + }) + expect(bundle.success).toBe(true) + const entry = path.join(tmp.path, "azure.mjs") + await Bun.write(entry, bundle.outputs[0]) + const cli = await azureCli(tmp.path) + for (const installed of [false, true]) { + const result = await Process.run( + [ + node, + "--input-type=module", + "-e", + ` + import assert from "node:assert/strict" + import { AzureAuthPlugin } from ${JSON.stringify(pathToFileURL(entry).href)} + assert.equal(typeof Bun, "undefined") + delete process.env.AZURE_RESOURCE_NAME + const hooks = await AzureAuthPlugin({ $: undefined }) + assert.equal(hooks.auth.provider, "azure") + assert.deepEqual(hooks.auth.methods.map((method) => method.type), ${JSON.stringify(installed ? ["api", "oauth"] : ["api"])}) + if (${installed}) { + const method = hooks.auth.methods.find((method) => method.type === "oauth") + assert.equal(method.prompts[0].type, "text") + const authorization = await method.authorize({ resourceName: "test-resource" }) + const auth = await authorization.callback() + assert.equal(auth.type, "success") + assert.equal(auth.accountId, "test-resource") + } + `, + ], + { + env: { PATH: installed ? cli.bin : tmp.path, XDG_DATA_HOME: tmp.path }, + nothrow: true, + }, + ) + expect(result.stderr.toString()).toBe("") + expect(result.code).toBe(0) + } + expect(await cli.calls()).toEqual([ + ["account", "get-access-token", "--scope", "https://cognitiveservices.azure.com/.default", "--output", "json"], + ]) + }) + + test("does not invoke Azure CLI during initialization", async () => { + await using tmp = await tmpdir() + const cli = await azureCli(tmp.path) + process.env.PATH = cli.bin + delete process.env.AZURE_RESOURCE_NAME + + const hooks = await AzureAuthPlugin() + + expect(await cli.calls()).toEqual([]) + expect(oauthMethod(hooks).prompts?.[0].type).toBe("text") + }) + + test("keeps the existing API-key method and adds Entra ID", () => { + delete process.env.AZURE_RESOURCE_NAME + const hooks = createAzureAuthHooks(azureShell([]), fetch, true) + + expect(hooks.auth?.provider).toBe("azure") + expect(hooks.auth?.methods.map((method) => [method.type, method.label])).toEqual([ + ["api", "API key"], + ["oauth", "Microsoft Entra ID (Azure CLI)"], + ]) + expect(hooks.auth?.methods[0]).toEqual({ + type: "api", + label: "API key", + prompts: [ + { + type: "text", + key: "resourceName", + message: "Enter Azure Resource Name", + placeholder: "e.g. my-models", + }, + ], + }) + expect(hooks.auth?.methods[1].prompts).toEqual(hooks.auth?.methods[0].prompts) + }) + + test("hides Azure CLI authentication when the Azure CLI is not installed", () => { + const hooks = createAzureAuthHooks(azureShell([]), fetch, false) + + expect(hooks.auth?.methods.map((method) => method.type)).toEqual(["api"]) + }) + + test("checks Azure CLI and stores the resource name", async () => { + const scopes: string[] = [] + const hooks = createAzureAuthHooks(azureShell(scopes), fetch, true) + const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" }) + if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method") + + expect(await authorization.callback()).toMatchObject({ + type: "success", + access: OAUTH_DUMMY_KEY, + refresh: OAUTH_DUMMY_KEY, + accountId: "test-resource", + }) + expect(scopes).toEqual(["https://cognitiveservices.azure.com/.default"]) + }) + + test("supports Azure CLI versions that only provide expiresOn", async () => { + const hooks = createAzureAuthHooks( + async () => ({ + accessToken: "legacy-token", + expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), + fetch, + true, + ) + const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" }) + if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method") + + expect(await authorization.callback()).toMatchObject({ type: "success", accountId: "test-resource" }) + }) + + test("rejects Azure CLI tokens without a usable expiration", async () => { + const hooks = createAzureAuthHooks(async () => ({ accessToken: "invalid-token" }), fetch, true) + const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" }) + if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method") + + await expect(authorization.callback()).rejects.toThrow("Azure CLI returned an invalid token expiration") + }) + + test("does not change API-key loading", async () => { + const scopes: string[] = [] + const hooks = createAzureAuthHooks(azureShell(scopes), fetch, true) + + expect(await loader(hooks)(async () => ({ type: "api", key: "test-key" }), provider)).toEqual({}) + expect(scopes).toEqual([]) + }) + + test("uses Azure CLI bearer tokens for Azure inference endpoints", async () => { + const scopes: string[] = [] + const requests: Headers[] = [] + const hooks = createAzureAuthHooks( + azureShell(scopes), + async (_input, init) => { + requests.push(new Headers(init?.headers)) + return new Response(null, { status: 200 }) + }, + true, + ) + const options = await loader(hooks)(async () => oauth, provider) + const request = customFetch(options) + + await request("https://test-resource.openai.azure.com/openai/v1/responses", { + headers: { "api-key": OAUTH_DUMMY_KEY, "x-keep": "yes" }, + }) + await request("https://test-resource.services.ai.azure.com/models/chat/completions", { + headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}` }, + }) + await request("https://test-resource.services.ai.azure.com/anthropic/v1/messages", { + headers: { "x-api-key": OAUTH_DUMMY_KEY }, + }) + + expect(scopes).toEqual(["https://cognitiveservices.azure.com/.default", "https://ai.azure.com/.default"]) + expect(requests.map((headers) => headers.get("authorization"))).toEqual([ + "Bearer https://cognitiveservices.azure.com/.default-token", + "Bearer https://cognitiveservices.azure.com/.default-token", + "Bearer https://ai.azure.com/.default-token", + ]) + expect(requests[0].get("api-key")).toBeNull() + expect(requests[0].get("x-keep")).toBe("yes") + expect(requests[2].get("x-api-key")).toBeNull() + expect(requests.every((headers) => headers.get("user-agent")?.startsWith("opencode/"))).toBe(true) + }) +}) diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..159f7806a75e1279c073eefe46b1b8dc15f09a7a --- /dev/null +++ b/packages/opencode/test/plugin/codex.test.ts @@ -0,0 +1,498 @@ +import { describe, expect, test } from "bun:test" +import { createServer, type IncomingMessage } from "node:http" +import { type AddressInfo } from "node:net" +import { WebSocketServer } from "ws" +import { + CodexAuthPlugin, + parseJwtClaims, + extractAccountIdFromClaims, + extractAccountId, + extractResidency, + renderOAuthError, + type IdTokenClaims, +} from "../../src/plugin/openai/codex" + +function createTestJwt(payload: object): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url") + const body = Buffer.from(JSON.stringify(payload)).toString("base64url") + return `${header}.${body}.sig` +} + +describe("plugin.codex", () => { + test("escapes provider errors in callback HTML", () => { + const error = `` + const html = renderOAuthError(error) + + expect(html).toContain("</div><script>alert("xss" & 'more')</script>") + expect(html).not.toContain(error) + }) + + describe("parseJwtClaims", () => { + test("parses valid JWT with claims", () => { + const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" } + const jwt = createTestJwt(payload) + const claims = parseJwtClaims(jwt) + expect(claims).toEqual(payload) + }) + + test("returns undefined for JWT with less than 3 parts", () => { + expect(parseJwtClaims("invalid")).toBeUndefined() + expect(parseJwtClaims("only.two")).toBeUndefined() + }) + + test("returns undefined for invalid base64", () => { + expect(parseJwtClaims("a.!!!invalid!!!.b")).toBeUndefined() + }) + + test("returns undefined for invalid JSON payload", () => { + const header = Buffer.from("{}").toString("base64url") + const invalidJson = Buffer.from("not json").toString("base64url") + expect(parseJwtClaims(`${header}.${invalidJson}.sig`)).toBeUndefined() + }) + }) + + describe("extractAccountIdFromClaims", () => { + test("extracts chatgpt_account_id from root", () => { + const claims: IdTokenClaims = { chatgpt_account_id: "acc-root" } + expect(extractAccountIdFromClaims(claims)).toBe("acc-root") + }) + + test("extracts chatgpt_account_id from nested https://api.openai.com/auth", () => { + const claims: IdTokenClaims = { + "https://api.openai.com/auth": { chatgpt_account_id: "acc-nested" }, + } + expect(extractAccountIdFromClaims(claims)).toBe("acc-nested") + }) + + test("prefers root over nested", () => { + const claims: IdTokenClaims = { + chatgpt_account_id: "acc-root", + "https://api.openai.com/auth": { chatgpt_account_id: "acc-nested" }, + } + expect(extractAccountIdFromClaims(claims)).toBe("acc-root") + }) + + test("extracts from organizations array as fallback", () => { + const claims: IdTokenClaims = { + organizations: [{ id: "org-123" }, { id: "org-456" }], + } + expect(extractAccountIdFromClaims(claims)).toBe("org-123") + }) + + test("returns undefined when no accountId found", () => { + const claims: IdTokenClaims = { email: "test@example.com" } + expect(extractAccountIdFromClaims(claims)).toBeUndefined() + }) + }) + + describe("extractAccountId", () => { + test("extracts from id_token first", () => { + const idToken = createTestJwt({ chatgpt_account_id: "from-id-token" }) + const accessToken = createTestJwt({ chatgpt_account_id: "from-access-token" }) + expect( + extractAccountId({ + id_token: idToken, + access_token: accessToken, + refresh_token: "rt", + }), + ).toBe("from-id-token") + }) + + test("falls back to access_token when id_token has no accountId", () => { + const idToken = createTestJwt({ email: "test@example.com" }) + const accessToken = createTestJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "from-access" }, + }) + expect( + extractAccountId({ + id_token: idToken, + access_token: accessToken, + refresh_token: "rt", + }), + ).toBe("from-access") + }) + + test("returns undefined when no tokens have accountId", () => { + const token = createTestJwt({ email: "test@example.com" }) + expect( + extractAccountId({ + id_token: token, + access_token: token, + refresh_token: "rt", + }), + ).toBeUndefined() + }) + + test("handles missing id_token", () => { + const accessToken = createTestJwt({ chatgpt_account_id: "acc-123" }) + expect( + extractAccountId({ + id_token: "", + access_token: accessToken, + refresh_token: "rt", + }), + ).toBe("acc-123") + }) + }) + + describe("extractResidency", () => { + test("extracts compute residency from the namespaced auth claims", () => { + expect( + extractResidency( + createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "eu" }, + }), + ), + ).toBe("eu") + }) + + test("falls back to a root compute residency claim", () => { + expect(extractResidency(createTestJwt({ chatgpt_compute_residency: "us" }))).toBe("us") + }) + + test("supports compute residency values without maintaining a region list", () => { + expect( + extractResidency( + createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "ae" }, + }), + ), + ).toBe("ae") + expect( + extractResidency( + createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "future-region_1" }, + }), + ), + ).toBe("future-region_1") + }) + + test("ignores unconstrained and data residency values", () => { + expect( + extractResidency( + createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "no_constraint" }, + }), + ), + ).toBeUndefined() + expect( + extractResidency( + createTestJwt({ + "https://api.openai.com/auth": { chatgpt_data_residency: "gb" }, + }), + ), + ).toBeUndefined() + expect(extractResidency(createTestJwt({ chatgpt_compute_residency: "" }))).toBeUndefined() + expect(extractResidency("not-a-jwt")).toBeUndefined() + }) + + test("prefers a namespaced unconstrained value over a root residency", () => { + expect( + extractResidency( + createTestJwt({ + chatgpt_compute_residency: "eu", + "https://api.openai.com/auth": { chatgpt_compute_residency: "no_constraint" }, + }), + ), + ).toBeUndefined() + }) + }) + + test("installs websocket transport only when experimental websockets are enabled", async () => { + const disabled = await CodexAuthPlugin({} as never) + const enabled = await CodexAuthPlugin({} as never, { experimentalWebSockets: true }) + + const disabledOptions = await disabled.auth!.loader!( + async () => ({ type: "api", key: "sk-test" }) as never, + {} as never, + ) + const enabledOptions = await enabled.auth!.loader!( + async () => ({ type: "api", key: "sk-test" }) as never, + {} as never, + ) + + expect(disabledOptions.fetch).toBeUndefined() + expect(enabledOptions.fetch).toBeFunction() + await enabled.dispose?.() + }) + + test("sends token residency only to the ChatGPT Codex backend", async () => { + const requests: Array<{ path: string; residency: string | null }> = [] + using server = Bun.serve({ + port: 0, + fetch(request) { + requests.push({ + path: new URL(request.url).pathname, + residency: request.headers.get("x-openai-internal-codex-residency"), + }) + return new Response("{}") + }, + }) + const hooks = await CodexAuthPlugin({} as never, { + codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(), + }) + const loaded = await hooks.auth!.loader!( + async () => + ({ + type: "oauth", + refresh: "refresh", + access: createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "eu" }, + }), + expires: Date.now() + 60_000, + }) as never, + {} as never, + ) + + await loaded.fetch!("https://api.openai.com/v1/responses") + await loaded.fetch!(new URL("/other", server.url)) + + expect(requests).toEqual([ + { path: "/backend-api/codex/responses", residency: "eu" }, + { path: "/other", residency: null }, + ]) + }) + + test("sends token residency through the WebSocket transport", async () => { + await using server = await createCodexWebSocketServer() + const hooks = await CodexAuthPlugin({} as never, { + codexApiEndpoint: server.url, + experimentalWebSockets: true, + }) + const loaded = await hooks.auth!.loader!( + async () => + ({ + type: "oauth", + refresh: "refresh", + access: createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "eu" }, + }), + expires: Date.now() + 60_000, + }) as never, + {} as never, + ) + + const response = await loaded.fetch!("https://api.openai.com/v1/responses", { + method: "POST", + headers: { "session-id": "session-1" }, + body: JSON.stringify({ stream: true, input: "hi" }), + }) + + expect(await response.text()).toContain("data: [DONE]") + expect(server.headers()?.["x-openai-internal-codex-residency"]).toBe("eu") + await hooks.dispose?.() + }) + + test("filters unsupported modes and uses Codex context limits for OAuth GPT models", async () => { + const hooks = await CodexAuthPlugin({} as never) + const limit = { context: 1_050_000, input: 922_000, output: 128_000 } + const provider = { + models: { + ...Object.fromEntries( + ["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.7-pro"].map((id) => [ + id, + { id, api: { id }, limit, cost: {}, options: {} }, + ]), + ), + "gpt-5.4-pro": { + id: "gpt-5.4-pro", + api: { id: "gpt-5.4" }, + limit, + cost: {}, + options: { reasoningMode: "pro" }, + }, + "gpt-5.6-sol-high": { + id: "gpt-5.6-sol-high", + api: { id: "gpt-5.6-sol" }, + limit, + cost: {}, + options: { reasoningEffort: "high" }, + }, + }, + } + + const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never) + + expect(models["gpt-5.4"]?.limit).toEqual(limit) + expect(models["gpt-5.5"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) + expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) + expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) + expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) + expect(models["gpt-5.4-pro"]).toBeUndefined() + expect(models["gpt-5.7-pro"]).toBeDefined() + expect(models["gpt-5.6-sol-high"]).toBeDefined() + expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe( + provider.models as never, + ) + }) + + test.each([ + ["gpt-6-astra", true], + ["gpt-6", true], + ["gpt-6.0-astra", true], + ["gpt-7", true], + ["gpt-10", true], + ["gpt-5.5-astra", true], + ["gpt-5.9", true], + ["gpt-5.10", true], + ["gpt-5.10-astra", true], + ["gpt-5.40", true], + ["gpt-5", false], + ["gpt-5.4-astra", false], + ["gpt-5.04-astra", false], + ["gpt-4.1", false], + ["gpt-4.99", false], + ["gpt-5.5-pro", false], + ["gpt-5.6", false], + ["gpt-6garbage", true], + ["gpt-6.", true], + ["gpt-6.1.2", true], + ["not-a-gpt-model", false], + ])("filters OAuth model %s by GPT major and minor versions", async (id, allowed) => { + const hooks = await CodexAuthPlugin({} as never) + const provider = { + models: { + [id]: { id, api: { id }, limit: {}, cost: {}, options: {} }, + }, + } + + const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never) + + expect(Object.keys(models)).toEqual(allowed ? [id] : []) + }) + + test("deduplicates concurrent Codex token refreshes", async () => { + const refreshedAccess = createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "eu" }, + }) + let auth = { + type: "oauth" as const, + refresh: "refresh-old", + access: "", + expires: 0, + } + const authUpdates: Array<{ + body: { refresh: string; access: string; expires: number; accountId?: string } + }> = [] + let resolveRefresh: (() => void) | undefined + const refreshReady = new Promise((resolve) => { + resolveRefresh = resolve + }) + let refreshRequests = 0 + const apiRequests: { authorization: string | null; accountId: string | null; residency: string | null }[] = [] + + using server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/oauth/token") { + expect(await request.text()).toContain("refresh_token=refresh-old") + refreshRequests += 1 + await refreshReady + return Response.json({ + id_token: createTestJwt({ chatgpt_account_id: "acc-123" }), + access_token: refreshedAccess, + refresh_token: "refresh-new", + expires_in: 3600, + }) + } + + if (url.pathname === "/backend-api/codex/responses") { + apiRequests.push({ + authorization: request.headers.get("authorization"), + accountId: request.headers.get("ChatGPT-Account-Id"), + residency: request.headers.get("x-openai-internal-codex-residency"), + }) + return new Response("{}", { status: 200 }) + } + + return new Response("unexpected request", { status: 500 }) + }, + }) + + const hooks = await CodexAuthPlugin( + { + client: { + auth: { + async set(input: { body: { refresh: string; access: string; expires: number; accountId?: string } }) { + authUpdates.push(input) + auth = { + type: "oauth", + refresh: input.body.refresh, + access: input.body.access, + expires: input.body.expires, + ...(input.body.accountId && { accountId: input.body.accountId }), + } + }, + }, + } as never, + project: {} as never, + directory: "", + worktree: "", + experimental_workspace: { + register() {}, + }, + serverUrl: new URL("https://example.com"), + $: {} as never, + }, + { + issuer: server.url.origin, + codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(), + }, + ) + const loaded = await hooks.auth!.loader!(async () => auth as never, {} as never) + + const first = loaded.fetch!("https://api.openai.com/v1/responses") + const second = loaded.fetch!("https://api.openai.com/v1/responses") + + await waitFor(() => refreshRequests === 1) + expect(apiRequests).toHaveLength(0) + + resolveRefresh!() + await Promise.all([first, second]) + + expect(refreshRequests).toBe(1) + expect(authUpdates).toHaveLength(1) + expect(authUpdates[0]?.body.refresh).toBe("refresh-new") + expect(authUpdates[0]?.body.access).toBe(refreshedAccess) + expect(authUpdates[0]?.body.accountId).toBe("acc-123") + expect(apiRequests).toEqual([ + { authorization: `Bearer ${refreshedAccess}`, accountId: "acc-123", residency: "eu" }, + { authorization: `Bearer ${refreshedAccess}`, accountId: "acc-123", residency: "eu" }, + ]) + }) +}) + +async function waitFor(predicate: () => boolean) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > 1_000) throw new Error("timed out waiting for condition") + await new Promise((resolve) => setTimeout(resolve, 1)) + } +} + +async function createCodexWebSocketServer() { + let headers: IncomingMessage["headers"] | undefined + const server = createServer() + const sockets = new WebSocketServer({ server }) + sockets.on("connection", (socket, request) => { + headers = request.headers + socket.once("message", () => { + socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_123" } })) + }) + }) + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", resolve) + }) + const address = server.address() as AddressInfo + return { + url: `http://127.0.0.1:${address.port}/backend-api/codex/responses`, + headers: () => headers, + async [Symbol.asyncDispose]() { + for (const socket of sockets.clients) socket.terminate() + sockets.close() + server.close() + }, + } +} diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..005a6e78cb55af77c019c469814c816ca7cd0d0e --- /dev/null +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -0,0 +1,1306 @@ +import { afterEach, describe, expect, spyOn } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer } from "effect" +import fs from "fs/promises" +import path from "path" +import { pathToFileURL } from "url" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Config } from "@/config/config" +import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const { Plugin } = await import("../../src/plugin/index") +const { PluginLoader } = await import("../../src/plugin/loader") +const { readPackageThemes } = await import("../../src/plugin/shared") +const { Npm } = await import("@opencode-ai/core/npm") +const { TestConfig } = await import("../fixture/config") +const { RuntimeFlags } = await import("../../src/effect/runtime-flags") + +afterEach(async () => { + await disposeAllInstances() +}) + +const it = testEffect( + Layer.mergeAll(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node])), testInstanceStoreLayer), +) + +function withTmp( + init: (dir: string) => Promise, + body: (tmp: { path: string; extra: T }) => Effect.Effect, +) { + return Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const extra = yield* Effect.promise(() => init(dir)) + return yield* body({ path: dir, extra }) + }) +} + +function load(dir: string, flags?: Parameters[0]) { + const source = path.join(dir, "opencode.json") + return Effect.gen(function* () { + const config = yield* Effect.promise( + () => Bun.file(source).json() as Promise<{ plugin?: Array]> }>, + ) + const plugins = config.plugin ?? [] + return yield* Effect.gen(function* () { + const plugin = yield* Plugin.Service + yield* plugin.list() + }).pipe( + Effect.provide( + LayerNode.compile(Plugin.node, [ + [ + Config.node, + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })), + }), + directories: () => Effect.succeed([dir]), + }), + ], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })], + ]), + ), + provideInstance(dir), + ) + }) +} + +describe("plugin.loader.shared", () => { + it.live("loads a file:// plugin function export", () => + withTmp( + async (dir) => { + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "called.txt") + await Bun.write( + file, + [ + "export default async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "called")`, + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2), + ) + + return { mark } + }, + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called") + }), + ), + ) + + it.live("deduplicates same function exported as default and named", () => + withTmp( + async (dir) => { + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "count.txt") + await Bun.write(mark, "") + await Bun.write( + file, + [ + "const run = async () => {", + ` const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => "")`, + ` await Bun.write(${JSON.stringify(mark)}, text + "1")`, + " return {}", + "}", + "export default run", + "export const named = run", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2), + ) + + return { mark } + }, + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("1") + }), + ), + ) + + it.live("uses only default v1 server plugin when present", () => + withTmp( + async (dir) => { + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "count.txt") + await Bun.write( + file, + [ + "export default {", + ' id: "demo.v1-default",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "default")`, + " return {}", + " },", + "}", + "export const named = async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "named")`, + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2), + ) + + return { mark } + }, + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("default") + }), + ), + ) + + it.live("rejects v1 file server plugin without id", () => + withTmp( + async (dir) => { + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "called.txt") + await Bun.write( + file, + [ + "export default {", + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "called")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2), + ) + + return { mark } + }, + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false), + ) + + expect(called).toBe(false) + }), + ), + ) + + it.live("rejects v1 plugin that exports server and tui together", () => + withTmp( + async (dir) => { + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "called.txt") + await Bun.write( + file, + [ + "export default {", + ' id: "demo.mixed",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "server")`, + " return {}", + " },", + " tui: async () => {},", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2), + ) + + return { mark } + }, + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false), + ) + + expect(called).toBe(false) + }), + ), + ) + + it.live("resolves npm plugin specs with explicit and default versions", () => + withTmp( + async (dir) => { + const acme = path.join(dir, "node_modules", "acme-plugin") + const scope = path.join(dir, "node_modules", "scope-plugin") + await fs.mkdir(acme, { recursive: true }) + await fs.mkdir(scope, { recursive: true }) + await Bun.write( + path.join(acme, "package.json"), + JSON.stringify({ name: "acme-plugin", type: "module", main: "./index.js" }, null, 2), + ) + await Bun.write(path.join(acme, "index.js"), "export default { server: async () => ({}) }\n") + await Bun.write( + path.join(scope, "package.json"), + JSON.stringify({ name: "scope-plugin", type: "module", main: "./index.js" }, null, 2), + ) + await Bun.write(path.join(scope, "index.js"), "export default { server: async () => ({}) }\n") + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: ["acme-plugin", "scope-plugin@2.3.4"] }, null, 2), + ) + + return { acme, scope } + }, + (tmp) => + Effect.gen(function* () { + const add = spyOn(Npm, "add").mockImplementation(async (pkg) => { + if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined } + return { directory: tmp.extra.scope, entrypoint: undefined } + }) + + try { + yield* load(tmp.path) + + expect(add.mock.calls).toContainEqual(["acme-plugin@latest"]) + expect(add.mock.calls).toContainEqual(["scope-plugin@2.3.4"]) + } finally { + add.mockRestore() + } + }), + ), + ) + + it.live("loads npm server plugin from package ./server export", () => + withTmp( + async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const mark = path.join(dir, "server-called.txt") + await fs.mkdir(mod, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + type: "module", + exports: { + ".": "./index.js", + "./server": "./server.js", + "./tui": "./tui.js", + }, + }, + null, + 2, + ), + ) + await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n') + await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n') + await Bun.write( + path.join(mod, "server.js"), + [ + "export default {", + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "called")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + await Bun.write(path.join(mod, "tui.js"), "export default {}\n") + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2)) + + return { + mod, + mark, + } + }, + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + + try { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called") + } finally { + install.mockRestore() + } + }), + ), + ) + + it.live("loads npm server plugin from package server export without leading dot", () => + withTmp( + async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const dist = path.join(mod, "dist") + const mark = path.join(dir, "server-called.txt") + await fs.mkdir(dist, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + type: "module", + exports: { + ".": "./index.js", + "./server": "dist/server.js", + }, + }, + null, + 2, + ), + ) + await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n') + await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n') + await Bun.write( + path.join(dist, "server.js"), + [ + "export default {", + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "called")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2)) + + return { + mod, + mark, + } + }, + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + + try { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called") + } finally { + install.mockRestore() + } + }), + ), + ) + + it.live("loads npm server plugin from package main without leading dot", () => + withTmp( + async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const dist = path.join(mod, "dist") + const mark = path.join(dir, "main-called.txt") + await fs.mkdir(dist, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + type: "module", + main: "dist/index.js", + }, + null, + 2, + ), + ) + await Bun.write( + path.join(dist, "index.js"), + [ + "export default {", + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "called")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2)) + + return { + mod, + mark, + } + }, + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + + try { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called") + } finally { + install.mockRestore() + } + }), + ), + ) + + it.live("does not use npm package exports dot for server entry", () => + withTmp( + async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const mark = path.join(dir, "dot-server.txt") + await fs.mkdir(mod, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify({ + name: "acme-plugin", + type: "module", + exports: { ".": "./index.js" }, + }), + ) + await Bun.write( + path.join(mod, "index.js"), + [ + "export default {", + ' id: "demo.dot.server",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "called")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2)) + + return { mod, mark } + }, + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + + try { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false), + ) + + expect(called).toBe(false) + } finally { + install.mockRestore() + } + }), + ), + ) + + it.live("rejects npm server export that resolves outside plugin directory", () => + withTmp( + async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const outside = path.join(dir, "outside") + const mark = path.join(dir, "outside-server.txt") + await fs.mkdir(mod, { recursive: true }) + await fs.mkdir(outside, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + type: "module", + exports: { + ".": "./index.js", + "./server": "./escape/server.js", + }, + }, + null, + 2, + ), + ) + await Bun.write(path.join(mod, "index.js"), "export default {}\n") + await Bun.write( + path.join(outside, "server.js"), + [ + "export default {", + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "outside")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir") + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin"] }, null, 2)) + + return { + mod, + mark, + } + }, + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + + try { + yield* load(tmp.path) + const called = yield* Effect.promise(() => + Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false), + ) + expect(called).toBe(false) + } finally { + install.mockRestore() + } + }), + ), + ) + + it.live("skips legacy codex and copilot auth plugin specs", () => + withTmp( + async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + plugin: ["opencode-openai-codex-auth@1.0.0", "opencode-copilot-auth@1.0.0", "regular-plugin@1.0.0"], + }, + null, + 2, + ), + ) + }, + (_tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined }) + + try { + yield* load(_tmp.path) + + const pkgs = install.mock.calls.map((call) => call[0]) + expect(pkgs).toContain("regular-plugin@1.0.0") + expect(pkgs).not.toContain("opencode-openai-codex-auth@1.0.0") + expect(pkgs).not.toContain("opencode-copilot-auth@1.0.0") + } finally { + install.mockRestore() + } + }), + ), + ) + + it.live("skips broken plugin when install fails", () => + withTmp( + async (dir) => { + const ok = path.join(dir, "ok.ts") + const mark = path.join(dir, "ok.txt") + await Bun.write( + ok, + [ + "export default {", + ' id: "demo.ok",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "ok")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: ["broken-plugin@9.9.9", pathToFileURL(ok).href] }, null, 2), + ) + return { mark } + }, + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) + + try { + yield* load(tmp.path) + expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9") + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok") + } finally { + install.mockRestore() + } + }), + ), + ) + + it.live("continues loading plugins when plugin init throws", () => + withTmp( + async (dir) => { + const file = pathToFileURL(path.join(dir, "throws.ts")).href + const ok = pathToFileURL(path.join(dir, "ok.ts")).href + const mark = path.join(dir, "ok.txt") + await Bun.write( + path.join(dir, "throws.ts"), + [ + "export default {", + ' id: "demo.throws",', + " server: async () => {", + ' throw new Error("explode")', + " },", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(dir, "ok.ts"), + [ + "export default {", + ' id: "demo.ok",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "ok")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2)) + + return { mark } + }, + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok") + }), + ), + ) + + it.live("continues loading plugins when plugin module has invalid export", () => + withTmp( + async (dir) => { + const file = pathToFileURL(path.join(dir, "invalid.ts")).href + const ok = pathToFileURL(path.join(dir, "ok.ts")).href + const mark = path.join(dir, "ok.txt") + await Bun.write( + path.join(dir, "invalid.ts"), + ["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"), + ) + await Bun.write( + path.join(dir, "ok.ts"), + [ + "export default {", + ' id: "demo.ok",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "ok")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2)) + + return { mark } + }, + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok") + }), + ), + ) + + it.live("continues loading plugins when plugin import fails", () => + withTmp( + async (dir) => { + const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href + const ok = pathToFileURL(path.join(dir, "ok.ts")).href + const mark = path.join(dir, "ok.txt") + await Bun.write( + path.join(dir, "ok.ts"), + [ + "export default {", + ' id: "demo.ok",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "ok")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2)) + + return { mark } + }, + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok") + }), + ), + ) + + it.live("loads object plugin via plugin.server", () => + withTmp( + async (dir) => { + const file = path.join(dir, "object-plugin.ts") + const mark = path.join(dir, "object-called.txt") + await Bun.write( + file, + [ + "const plugin = {", + ' id: "demo.object",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "called")`, + " return {}", + " },", + "}", + "export default plugin", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2), + ) + + return { mark } + }, + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called") + }), + ), + ) + + it.live("passes tuple plugin options into server plugin", () => + withTmp( + async (dir) => { + const file = path.join(dir, "options-plugin.ts") + const mark = path.join(dir, "options.json") + await Bun.write( + file, + [ + "const plugin = {", + ' id: "demo.options",', + " server: async (_input, options) => {", + ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(options ?? null))`, + " return {}", + " },", + "}", + "export default plugin", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [[pathToFileURL(file).href, { source: "tuple", enabled: true }]] }, null, 2), + ) + + return { mark } + }, + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + expect( + (yield* (yield* FSUtil.Service).readJson(tmp.extra.mark)) as { source: string; enabled: boolean }, + ).toEqual({ + source: "tuple", + enabled: true, + }) + }), + ), + ) + + it.live("initializes server plugins in config order", () => + withTmp( + async (dir) => { + const a = path.join(dir, "a-plugin.ts") + const b = path.join(dir, "b-plugin.ts") + const marker = path.join(dir, "server-order.txt") + const aSpec = pathToFileURL(a).href + const bSpec = pathToFileURL(b).href + + await Bun.write( + a, + `import fs from "fs/promises" + +export default { + id: "demo.order.a", + server: async () => { + await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n") + await Bun.sleep(25) + await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n") + return {} + }, +} +`, + ) + await Bun.write( + b, + `import fs from "fs/promises" + +export default { + id: "demo.order.b", + server: async () => { + await fs.appendFile(${JSON.stringify(marker)}, "b\\n") + return {} + }, +} +`, + ) + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2)) + + return { marker } + }, + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path) + const lines = (yield* Effect.promise(() => fs.readFile(tmp.extra.marker, "utf8"))).trim().split("\n") + expect(lines).toEqual(["a-start", "a-end", "b"]) + }), + ), + ) + + it.live("skips external plugins in pure mode", () => + withTmp( + async (dir) => { + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "called.txt") + await Bun.write( + file, + [ + "export default {", + ' id: "demo.pure",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "called")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2), + ) + + return { mark } + }, + (tmp) => + Effect.gen(function* () { + yield* load(tmp.path, { pure: true }) + const called = yield* Effect.promise(() => + fs + .readFile(tmp.extra.mark, "utf8") + .then(() => true) + .catch(() => false), + ) + expect(called).toBe(false) + }), + ), + ) + + it.live("reads oc-themes from package manifest", () => + withTmp( + async (dir) => { + const mod = path.join(dir, "mod") + await fs.mkdir(path.join(mod, "themes"), { recursive: true }) + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + version: "1.0.0", + "oc-themes": ["themes/one.json", "./themes/one.json", "themes/two.json"], + }, + null, + 2, + ), + ) + + return { mod } + }, + (tmp) => + Effect.gen(function* () { + const file = path.join(tmp.extra.mod, "package.json") + const fsys = yield* FSUtil.Service + const json = (yield* fsys.readJson(file)) as Record + const list = readPackageThemes("acme-plugin", { + dir: tmp.extra.mod, + pkg: file, + json, + }) + + expect(list).toEqual([ + FSUtil.resolve(path.join(tmp.extra.mod, "themes", "one.json")), + FSUtil.resolve(path.join(tmp.extra.mod, "themes", "two.json")), + ]) + }), + ), + ) + + it.live("handles no-entrypoint tui packages via missing callback", () => + withTmp( + async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + await fs.mkdir(path.join(mod, "themes"), { recursive: true }) + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + version: "1.0.0", + "oc-themes": ["themes/night.json"], + }, + null, + 2, + ), + ) + await Bun.write(path.join(mod, "themes", "night.json"), "{}\n") + return { mod } + }, + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + const missing: string[] = [] + + try { + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: "acme-plugin@1.0.0", + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + missing: async (item) => { + if (!item.pkg) return + const themes = readPackageThemes(item.spec, item.pkg) + if (!themes.length) return + return { + spec: item.spec, + target: item.target, + themes, + } + }, + report: { + missing(_candidate, _retry, message) { + missing.push(message) + }, + }, + }), + ) + + expect(loaded).toEqual([ + { + spec: "acme-plugin@1.0.0", + target: tmp.extra.mod, + themes: [FSUtil.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], + }, + ]) + expect(missing).toHaveLength(0) + } finally { + install.mockRestore() + } + }), + ), + ) + + it.live("passes package metadata for entrypoint tui plugins", () => + withTmp( + async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + await fs.mkdir(path.join(mod, "themes"), { recursive: true }) + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + version: "1.0.0", + exports: { + "./tui": "./tui.js", + }, + "oc-themes": ["themes/night.json"], + }, + null, + 2, + ), + ) + await Bun.write(path.join(mod, "tui.js"), 'export default { id: "demo", tui: async () => {} }\n') + await Bun.write(path.join(mod, "themes", "night.json"), "{}\n") + return { mod } + }, + (tmp) => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) + + try { + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: "acme-plugin@1.0.0", + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + finish: async (item) => { + if (!item.pkg) return + return { + spec: item.spec, + themes: readPackageThemes(item.spec, item.pkg), + } + }, + }), + ) + + expect(loaded).toEqual([ + { + spec: "acme-plugin@1.0.0", + themes: [FSUtil.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], + }, + ]) + } finally { + install.mockRestore() + } + }), + ), + ) + + it.live("rejects oc-themes path traversal", () => + withTmp( + async (dir) => { + const mod = path.join(dir, "mod") + await fs.mkdir(mod, { recursive: true }) + const file = path.join(mod, "package.json") + await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2)) + return { mod, file } + }, + (tmp) => + Effect.gen(function* () { + const fsys = yield* FSUtil.Service + const json = (yield* fsys.readJson(tmp.extra.file)) as Record + expect(() => + readPackageThemes("acme", { + dir: tmp.extra.mod, + pkg: tmp.extra.file, + json, + }), + ).toThrow("outside plugin directory") + }), + ), + ) + + it.live("retries failed file plugins once after wait and keeps order", () => + withTmp( + async (dir) => { + const a = path.join(dir, "a") + const b = path.join(dir, "b") + const aSpec = pathToFileURL(a).href + const bSpec = pathToFileURL(b).href + await fs.mkdir(a, { recursive: true }) + await fs.mkdir(b, { recursive: true }) + return { a, b, aSpec, bSpec } + }, + (tmp) => + Effect.gen(function* () { + let wait = 0 + const calls: Array<[string, boolean]> = [] + + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({ + spec, + scope: "local" as const, + source: tmp.path, + })), + kind: "tui", + wait: async () => { + wait += 1 + await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n") + await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n") + }, + report: { + start(candidate, retry) { + calls.push([candidate.plan.spec, retry]) + }, + }, + }), + ) + + expect(wait).toBe(1) + expect(calls).toEqual([ + [tmp.extra.aSpec, false], + [tmp.extra.bSpec, false], + [tmp.extra.aSpec, true], + [tmp.extra.bSpec, true], + ]) + expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec]) + }), + ), + ) + + it.live("does not retry permanent file plugin entry errors", () => + withTmp( + async (dir) => { + const mod = path.join(dir, "bad-entry") + const spec = pathToFileURL(mod).href + await fs.mkdir(mod, { recursive: true }) + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify({ exports: { "./tui": "../outside.js" } }, null, 2), + ) + return { spec } + }, + (tmp) => + Effect.gen(function* () { + let wait = 0 + const errors: Array<[string, boolean]> = [] + + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: tmp.extra.spec, + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + wait: async () => { + wait += 1 + }, + report: { + error(_candidate, retry, stage) { + errors.push([stage, retry]) + }, + }, + }), + ) + + expect(loaded).toEqual([]) + expect(wait).toBe(0) + expect(errors).toEqual([["entry", false]]) + }), + ), + ) + + it.live("does not retry file plugins when finish returns undefined", () => + withTmp( + async (dir) => { + const file = path.join(dir, "plugin.ts") + const spec = pathToFileURL(file).href + await Bun.write(file, "export default {}\n") + return { spec } + }, + (tmp) => + Effect.gen(function* () { + let wait = 0 + let count = 0 + + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: tmp.extra.spec, + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + wait: async () => { + wait += 1 + }, + finish: async () => { + count += 1 + }, + }), + ) + + expect(wait).toBe(0) + expect(count).toBe(1) + expect(loaded).toEqual([]) + }), + ), + ) + + it.live("does not wait or retry npm plugin failures", () => + Effect.gen(function* () { + const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) + let wait = 0 + const errors: Array<[string, boolean]> = [] + + try { + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: "acme-plugin@1.0.0", + scope: "local" as const, + source: "test", + }, + ], + kind: "tui", + wait: async () => { + wait += 1 + }, + report: { + error(_candidate, retry, stage) { + errors.push([stage, retry]) + }, + }, + }), + ) + + expect(loaded).toEqual([]) + expect(wait).toBe(0) + expect(errors).toEqual([["install", false]]) + } finally { + install.mockRestore() + } + }), + ) +}) diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..07fd8e2f21bc51e80ecdfcb61c1851c7fd057db1 --- /dev/null +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -0,0 +1,108 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Npm } from "@opencode-ai/core/npm" +import path from "path" +import { pathToFileURL } from "url" +import { Account } from "../../src/account/account" +import { Auth } from "../../src/auth" +import { RuntimeFlags } from "../../src/effect/runtime-flags" +import { Plugin } from "../../src/plugin/index" + +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { AccountTest } from "../fake/account" +import { AuthTest } from "../fake/auth" +import { NpmTest } from "../fake/npm" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Plugin.node, CrossSpawnSpawner.node]), [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], + ]), +) +const systemHook = "experimental.chat.system.transform" + +function withProject(source: string, self: Effect.Effect) { + return Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "plugin.ts") + yield* Effect.all( + [ + Effect.promise(() => Bun.write(file, source)), + Effect.promise(() => + Bun.write( + path.join(test.directory, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ), + ), + ], + { discard: true, concurrency: 2 }, + ) + return yield* self + }) +} + +const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransform")(function* () { + const plugin = yield* Plugin.Service + const out = { system: [] as string[] } + yield* plugin.trigger( + systemHook, + { + model: { + providerID: ProviderV2.ID.anthropic, + modelID: ModelV2.ID.make("claude-sonnet-4-6"), + }, + }, + out, + ) + return out.system +}) + +describe("plugin.trigger", () => { + it.instance("runs synchronous hooks without crashing", () => + withProject( + [ + "export default async () => ({", + ` ${JSON.stringify(systemHook)}: (_input, output) => {`, + ' output.system.unshift("sync")', + " },", + "})", + "", + ].join("\n"), + Effect.gen(function* () { + expect(yield* triggerSystemTransform()).toEqual(["sync"]) + }), + ), + ) + + it.instance("awaits asynchronous hooks", () => + withProject( + [ + "export default async () => ({", + ` ${JSON.stringify(systemHook)}: async (_input, output) => {`, + " await Bun.sleep(1)", + ' output.system.unshift("async")', + " },", + "})", + "", + ].join("\n"), + Effect.gen(function* () { + expect(yield* triggerSystemTransform()).toEqual(["async"]) + }), + ), + ) +}) diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8dd172521726a5d847b30ef8ca49a38bd0951df --- /dev/null +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Npm } from "@opencode-ai/core/npm" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import path from "path" +import { pathToFileURL } from "url" +import { Auth } from "../../src/auth" +import { Account } from "../../src/account/account" +import { RuntimeFlags } from "../../src/effect/runtime-flags" +import { Workspace } from "../../src/control-plane/workspace" +import { Plugin } from "../../src/plugin/index" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" +import { InstanceState } from "../../src/effect/instance-state" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { AccountTest } from "../fake/account" +import { AuthTest } from "../fake/auth" +import { NpmTest } from "../fake/npm" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Plugin.node, Workspace.node, InstanceStore.node, Ripgrep.node]), [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [InstanceStore.bootstrapNode, noopBootstrapLayer], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true, experimentalWorkspaces: true })], + ]), +) + +afterEach(async () => { + await disposeAllInstances() +}) + +describe("plugin.workspace", () => { + it.instance("plugin can install a workspace adapter", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const type = `plug-${Math.random().toString(36).slice(2)}` + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "created.json") + const space = path.join(dir, "space") + yield* Effect.promise(() => + Bun.write( + file, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "plug",', + ' description: "plugin workspace adapter",', + " configure(input) {", + ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, + " },", + " async create(input) {", + ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`, + " },", + " async remove() {},", + " target(input) {", + ' return { type: "local", directory: input.directory }', + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ), + ) + + yield* Effect.promise(() => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ), + ) + + const plugin = yield* Plugin.Service + yield* plugin.init() + const workspace = yield* Workspace.Service + const ctx = yield* InstanceState.context + const info = yield* workspace.create({ + type, + branch: null, + extra: { key: "value" }, + projectID: ctx.project.id, + }) + + expect(info.type).toBe(type) + expect(info.name).toBe("plug") + expect(info.branch).toBe("plug/main") + expect(info.directory).toBe(space) + expect(info.extra).toEqual({ key: "value" }) + expect(JSON.parse(yield* Effect.promise(() => Bun.file(mark).text()))).toMatchObject({ + type, + name: "plug", + branch: "plug/main", + directory: space, + extra: { key: "value" }, + }) + }), + ) +}) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts new file mode 100644 index 0000000000000000000000000000000000000000..16b4789b0725b50cd9056f0ff9b9885bcb9139b3 --- /dev/null +++ b/packages/opencode/test/preload.ts @@ -0,0 +1,92 @@ +// IMPORTANT: Set env vars BEFORE any imports from src/ directory +// xdg-basedir reads env vars at import time, so we must set these first +import os from "os" +import path from "path" +import fs from "fs/promises" +import { setTimeout as sleep } from "node:timers/promises" +import { afterAll } from "bun:test" + +// Set XDG env vars FIRST, before any src/ imports +const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid) +await fs.mkdir(dir, { recursive: true }) +afterAll(async () => { + const { AppRuntime } = await import("../src/effect/app-runtime") + await AppRuntime.dispose() + + const busy = (error: unknown) => + typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY" + const rm = async (left: number): Promise => { + Bun.gc(true) + await sleep(100) + return fs.rm(dir, { recursive: true, force: true }).catch((error) => { + if (!busy(error)) throw error + if (left <= 1 && process.platform !== "win32") throw error + if (left <= 1) return + return rm(left - 1) + }) + } + + // Windows can keep SQLite WAL handles alive until GC finalizers run, so we + // force GC and retry teardown to avoid flaky EBUSY in test cleanup. + await rm(30) +}) + +process.env["XDG_DATA_HOME"] = path.join(dir, "share") +process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") +process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") +process.env["XDG_STATE_HOME"] = path.join(dir, "state") +process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") +process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true" +process.env["OPENCODE_EXPERIMENTAL_WORKSPACES"] = "true" + +// Set test home directory to isolate tests from user's actual home directory +// This prevents tests from picking up real user configs/skills from ~/.claude/skills +const testHome = path.join(dir, "home") +await fs.mkdir(testHome, { recursive: true }) +process.env["OPENCODE_TEST_HOME"] = testHome + +// Set test managed config directory to isolate tests from system managed settings +const testManagedConfigDir = path.join(dir, "managed") +process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir + +// Write the cache version file to prevent global/index.ts from clearing the cache +const cacheDir = path.join(dir, "cache", "opencode") +await fs.mkdir(cacheDir, { recursive: true }) +await fs.writeFile(path.join(cacheDir, "version"), "14") + +// Clear provider and server auth env vars to ensure clean test state +delete process.env["ANTHROPIC_API_KEY"] +delete process.env["OPENAI_API_KEY"] +delete process.env["GOOGLE_API_KEY"] +delete process.env["GOOGLE_GENERATIVE_AI_API_KEY"] +delete process.env["AZURE_OPENAI_API_KEY"] +delete process.env["AWS_ACCESS_KEY_ID"] +delete process.env["AWS_PROFILE"] +delete process.env["AWS_REGION"] +delete process.env["AWS_BEARER_TOKEN_BEDROCK"] +delete process.env["OPENROUTER_API_KEY"] +delete process.env["LLM_GATEWAY_API_KEY"] +delete process.env["GROQ_API_KEY"] +delete process.env["MISTRAL_API_KEY"] +delete process.env["PERPLEXITY_API_KEY"] +delete process.env["TOGETHER_API_KEY"] +delete process.env["XAI_API_KEY"] +delete process.env["DEEPSEEK_API_KEY"] +delete process.env["FIREWORKS_API_KEY"] +delete process.env["CEREBRAS_API_KEY"] +delete process.env["SAMBANOVA_API_KEY"] +delete process.env["OPENCODE_SERVER_PASSWORD"] +delete process.env["OPENCODE_SERVER_USERNAME"] +delete process.env["OPENCODE_EXPERIMENTAL"] +delete process.env["OPENCODE_ENABLE_EXPERIMENTAL_MODELS"] +delete process.env["OTEL_EXPORTER_OTLP_ENDPOINT"] +delete process.env["OTEL_EXPORTER_OTLP_HEADERS"] +delete process.env["OTEL_RESOURCE_ATTRIBUTES"] + +// Use in-memory sqlite +process.env["OPENCODE_DB"] = ":memory:" + +// Now safe to import from src/ +const { initProjectors } = await import("../src/server/projectors") + +initProjectors() diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4654ddb3d7a8c08976ac0d6376445c68ff27056 --- /dev/null +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -0,0 +1,404 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" +import path from "path" +import { unlink } from "fs/promises" +import { Global } from "@opencode-ai/core/global" +import { Filesystem } from "@/util/filesystem" +import { Env } from "../../src/env" +import { Provider } from "@/provider/provider" + +import { disposeAllInstances } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" + +const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node]))) + +const originalEnv = new Map() + +const set = (k: string, v: string) => + Effect.gen(function* () { + if (!originalEnv.has(k)) originalEnv.set(k, process.env[k]) + process.env[k] = v + yield* Env.use.set(k, v) + }) + +afterEach(async () => { + for (const [key, value] of originalEnv) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + originalEnv.clear() + await disposeAllInstances() +}) + +const list = Provider.use.list() + +const mantleModelConfig = { + provider: { npm: "@ai-sdk/amazon-bedrock/mantle" }, + limit: { context: 272_000, output: 32_000 }, + modalities: { + input: ["text", "image", "pdf"] as Array<"text" | "image" | "pdf">, + output: ["text"] as Array<"text">, + }, +} + +const withAuthJson = (contents: string) => + Effect.acquireRelease( + Effect.promise(async () => { + const authPath = path.join(Global.Path.data, "auth.json") + let original: string | undefined + try { + original = await Filesystem.readText(authPath) + } catch { + original = undefined + } + await Filesystem.write(authPath, contents) + return { authPath, original } + }), + ({ authPath, original }) => + Effect.promise(async () => { + if (original !== undefined) { + await Filesystem.write(authPath, original) + return + } + await unlink(authPath).catch(() => undefined) + }), + ) + +it.instance( + "Bedrock: config region takes precedence over AWS_REGION env var", + () => + Effect.gen(function* () { + yield* set("AWS_REGION", "us-east-1") + yield* set("AWS_PROFILE", "default") + const providers = yield* list + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1") + }), + { config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } }, +) + +it.instance("Bedrock: falls back to AWS_REGION env var when no config region", () => + Effect.gen(function* () { + yield* set("AWS_REGION", "eu-west-1") + yield* set("AWS_PROFILE", "default") + const providers = yield* list + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1") + }), +) + +it.instance( + "Bedrock: loads when bearer token from auth.json is present", + () => + Effect.gen(function* () { + yield* withAuthJson(JSON.stringify({ "amazon-bedrock": { type: "api", key: "test-bearer-token" } })) + yield* set("AWS_PROFILE", "") + yield* set("AWS_ACCESS_KEY_ID", "") + yield* set("AWS_BEARER_TOKEN_BEDROCK", "") + const providers = yield* list + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1") + }), + { config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } }, +) + +it.instance( + "Bedrock Mantle: GPT-5.5 uses Responses API and OpenAI base path", + () => + Effect.gen(function* () { + yield* set("AWS_REGION", "") + yield* set("AWS_PROFILE", "") + yield* set("AWS_ACCESS_KEY_ID", "") + yield* set("AWS_BEARER_TOKEN_BEDROCK", "") + const model = yield* Provider.use.getModel(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")) + const language = yield* Provider.use.getLanguage(model) + expect((language as { provider: string }).provider).toBe("bedrock-mantle.responses") + expect((language as { modelId: string }).modelId).toBe("openai.gpt-5.5") + expect( + (language as unknown as { config: { url: (input: { path: string; modelId: string }) => string } }).config.url({ + path: "/responses", + modelId: "openai.gpt-5.5", + }), + ).toBe("https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses") + }), + { + config: { + provider: { + "amazon-bedrock": { + options: { region: "us-east-2", apiKey: "test-bearer-token" }, + models: { + "openai.gpt-5.5": { + ...mantleModelConfig, + provider: { + npm: "@ai-sdk/amazon-bedrock/mantle", + api: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1", + }, + }, + }, + }, + }, + }, + }, +) + +it.instance( + "Bedrock Mantle: GPT OSS safeguard uses Chat Completions and Mantle base path", + () => + Effect.gen(function* () { + yield* set("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token") + const model = yield* Provider.use.getModel( + ProviderV2.ID.amazonBedrock, + ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), + ) + const language = yield* Provider.use.getLanguage(model) + expect((language as { provider: string }).provider).toBe("bedrock-mantle.chat") + expect((language as { modelId: string }).modelId).toBe("openai.gpt-oss-safeguard-120b") + expect( + (language as unknown as { config: { url: (input: { path: string; modelId: string }) => string } }).config.url({ + path: "/chat/completions", + modelId: "openai.gpt-oss-safeguard-120b", + }), + ).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions") + }), + { + config: { + provider: { + "amazon-bedrock": { + options: { region: "us-east-1" }, + models: { "openai.gpt-oss-safeguard-120b": mantleModelConfig }, + }, + }, + }, + }, +) + +it.instance( + "Bedrock: config profile takes precedence over AWS_PROFILE env var", + () => + Effect.gen(function* () { + yield* set("AWS_PROFILE", "default") + yield* set("AWS_ACCESS_KEY_ID", "test-key-id") + const providers = yield* list + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("us-east-1") + }), + { + config: { + provider: { "amazon-bedrock": { options: { profile: "my-custom-profile", region: "us-east-1" } } }, + }, + }, +) + +it.instance( + "Bedrock: includes custom endpoint in options when specified", + () => + Effect.gen(function* () { + yield* set("AWS_PROFILE", "default") + const providers = yield* list + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].options?.endpoint).toBe( + "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com", + ) + }), + { + config: { + provider: { + "amazon-bedrock": { + options: { endpoint: "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com" }, + }, + }, + }, + }, +) + +it.instance( + "Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", + () => + Effect.gen(function* () { + yield* set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") + yield* set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") + yield* set("AWS_PROFILE", "") + yield* set("AWS_ACCESS_KEY_ID", "") + const providers = yield* list + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("us-east-1") + }), + { config: { provider: { "amazon-bedrock": { options: { region: "us-east-1" } } } } }, +) + +it.instance( + "Bedrock: preserves explicit DeepSeek model identifiers", + () => + Effect.gen(function* () { + yield* set("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token") + const provider = yield* Provider.Service + const deepseek = yield* provider.getLanguage( + yield* provider.getModel(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("deepseek.v3.2")), + ) + const r1 = yield* provider.getLanguage( + yield* provider.getModel(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("deepseek.r1-v1:0")), + ) + const profile = yield* provider.getLanguage( + yield* provider.getModel(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("us.deepseek.r1-v1:0")), + ) + const arn = yield* provider.getLanguage( + yield* provider.getModel(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("deepseek-v3-2-arn")), + ) + expect((deepseek as { modelId: string }).modelId).toBe("deepseek.v3.2") + expect((r1 as { modelId: string }).modelId).toBe("us.deepseek.r1-v1:0") + expect((profile as { modelId: string }).modelId).toBe("us.deepseek.r1-v1:0") + expect((arn as { modelId: string }).modelId).toBe("arn:aws:bedrock:us-east-1::foundation-model/deepseek.v3.2") + }), + { + config: { + provider: { + "amazon-bedrock": { + options: { region: "us-east-1" }, + models: { + "deepseek.v3.2": { name: "DeepSeek V3.2" }, + "deepseek.r1-v1:0": { name: "DeepSeek R1" }, + "us.deepseek.r1-v1:0": { name: "DeepSeek R1 US" }, + "deepseek-v3-2-arn": { + id: "arn:aws:bedrock:us-east-1::foundation-model/deepseek.v3.2", + name: "DeepSeek V3.2 ARN", + }, + }, + }, + }, + }, + }, +) + +// Cross-region inference profile prefix handling. +// Models from models.dev may come with prefixes already (e.g. us., eu., global.). +// These should NOT be double-prefixed when passed to the SDK. + +it.instance( + "Bedrock: model with us. prefix should not be double-prefixed", + () => + Effect.gen(function* () { + yield* set("AWS_PROFILE", "default") + const providers = yield* list + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() + }), + { + config: { + provider: { + "amazon-bedrock": { + options: { region: "us-east-1" }, + models: { "us.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (US)" } }, + }, + }, + }, + }, +) + +it.instance( + "Bedrock: model with global. prefix should not be prefixed", + () => + Effect.gen(function* () { + yield* set("AWS_PROFILE", "default") + const providers = yield* list + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect( + providers[ProviderV2.ID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"], + ).toBeDefined() + }), + { + config: { + provider: { + "amazon-bedrock": { + options: { region: "us-east-1" }, + models: { "global.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (Global)" } }, + }, + }, + }, + }, +) + +it.instance( + "Bedrock: model with eu. prefix should not be double-prefixed", + () => + Effect.gen(function* () { + yield* set("AWS_PROFILE", "default") + const providers = yield* list + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() + }), + { + config: { + provider: { + "amazon-bedrock": { + options: { region: "eu-west-1" }, + models: { "eu.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (EU)" } }, + }, + }, + }, + }, +) + +it.instance( + "Bedrock: model without prefix in US region should get us. prefix added", + () => + Effect.gen(function* () { + yield* set("AWS_PROFILE", "default") + const providers = yield* list + expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined() + expect(providers[ProviderV2.ID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() + }), + { + config: { + provider: { + "amazon-bedrock": { + options: { region: "us-east-1" }, + models: { "anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5" } }, + }, + }, + }, + }, +) + +// Direct unit tests for cross-region inference profile prefix detection. +describe("Bedrock cross-region prefix detection", () => { + const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."] + + test("should detect global. prefix", () => { + expect(crossRegionPrefixes.some((p) => "global.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true) + }) + + test("should detect us. prefix", () => { + expect(crossRegionPrefixes.some((p) => "us.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true) + }) + + test("should detect eu. prefix", () => { + expect(crossRegionPrefixes.some((p) => "eu.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true) + }) + + test("should detect jp. prefix", () => { + expect(crossRegionPrefixes.some((p) => "jp.anthropic.claude-sonnet-4-20250514-v1:0".startsWith(p))).toBe(true) + }) + + test("should detect apac. prefix", () => { + expect(crossRegionPrefixes.some((p) => "apac.anthropic.claude-sonnet-4-20250514-v1:0".startsWith(p))).toBe(true) + }) + + test("should detect au. prefix", () => { + expect(crossRegionPrefixes.some((p) => "au.anthropic.claude-sonnet-4-5-20250929-v1:0".startsWith(p))).toBe(true) + }) + + test("should NOT detect prefix for non-prefixed model", () => { + expect(crossRegionPrefixes.some((p) => "anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(false) + }) + + test("should NOT detect prefix for amazon nova models", () => { + expect(crossRegionPrefixes.some((p) => "amazon.nova-pro-v1:0".startsWith(p))).toBe(false) + }) + + test("should NOT detect prefix for cohere models", () => { + expect(crossRegionPrefixes.some((p) => "cohere.command-r-plus-v1:0".startsWith(p))).toBe(false) + }) +}) diff --git a/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..97f9d4f909a844544f1d59a6286fb5ea1a8eb458 --- /dev/null +++ b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts @@ -0,0 +1,320 @@ +// End-to-end regression tests for opencode#24432 and opencode#32051/#32052. +// +// Routes through the actual ai-gateway-provider chain that provider.ts builds at +// runtime, with only the network boundary stubbed: +// - openai/* -> native OpenAI passthrough (Responses API) +// - anthropic/* -> native Anthropic passthrough (Messages API) +// - everything else -> unified /compat (openai-compatible chat completions) +// Asserts what actually lands in the envelope body Cloudflare AI Gateway +// forwards upstream, which is the only place these bugs were observable. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import type { JSONValue } from "ai" +import { generateText } from "ai" +import { createAiGateway } from "ai-gateway-provider" +import { createUnified } from "ai-gateway-provider/providers/unified" +import { createOpenAI } from "ai-gateway-provider/providers/openai" +import { createAnthropic } from "ai-gateway-provider/providers/anthropic" +import { ProviderTransform } from "@/provider/transform" +import type * as Provider from "@/provider/provider" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" + +type Captured = { url: string; outerBody: unknown; headers: Record } +type ProviderOptions = Record> + +const realFetch = globalThis.fetch +let captured: Captured | null = null + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +// The gateway returns the upstream provider's response body verbatim, so the +// mock must answer in the wire format of the step's target provider. +function upstreamResponseBody(provider: string | undefined) { + if (provider === "openai") + return { + id: "resp_test", + object: "response", + created_at: 0, + model: "gpt-5.4", + status: "completed", + error: null, + incomplete_details: null, + output: [ + { + type: "message", + role: "assistant", + id: "msg_1", + status: "completed", + content: [{ type: "output_text", text: "ok", annotations: [] }], + }, + ], + usage: { + input_tokens: 1, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 1, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 2, + }, + } + if (provider === "anthropic") + return { + id: "msg_test", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + } + return { + id: "chatcmpl-test", + object: "chat.completion", + created: 0, + model: "test", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } +} + +beforeEach(() => { + captured = null + const handle = async (input: Parameters[0], init?: Parameters[1]): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + if (url.startsWith("https://gateway.ai.cloudflare.com/")) { + const bodyText = typeof init?.body === "string" ? init.body : "" + const outerBody = bodyText ? JSON.parse(bodyText) : null + captured = { + url, + outerBody, + headers: Object.fromEntries(new Headers(init?.headers).entries()), + } + const provider = + Array.isArray(outerBody) && isRecord(outerBody[0]) && typeof outerBody[0].provider === "string" + ? outerBody[0].provider + : undefined + return new Response(JSON.stringify(upstreamResponseBody(provider)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + } + return realFetch(input, init) + } + // `typeof fetch` includes Bun's `preconnect` method; preserve it from realFetch. + const stubFetch: typeof fetch = Object.assign(handle, { preconnect: realFetch.preconnect.bind(realFetch) }) + globalThis.fetch = stubFetch +}) + +afterEach(() => { + globalThis.fetch = realFetch +}) + +// Mirrors the runtime npm rewrite in provider.ts: openai/anthropic models carry +// their native SDK package so transforms key provider options correctly. +const cfNpm = (apiId: string) => { + if (apiId.startsWith("openai/")) return "@ai-sdk/openai" + if (apiId.startsWith("anthropic/")) return "@ai-sdk/anthropic" + return "ai-gateway-provider" +} + +const cfModel = (apiId: string, releaseDate = "2026-03-05"): Provider.Model => ({ + id: ModelV2.ID.make(`cloudflare-ai-gateway/${apiId}`), + providerID: ProviderV2.ID.make("cloudflare-ai-gateway"), + name: apiId, + api: { id: apiId, url: "https://gateway.ai.cloudflare.com/v1/compat", npm: cfNpm(apiId) }, + capabilities: { + reasoning: true, + temperature: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 1, output: 1, cache: { read: 0, write: 0 } }, + limit: { context: 1_000_000, output: 128_000 }, + status: "active", + options: {}, + headers: {}, + release_date: releaseDate, +}) + +// ai-gateway-provider sends an array of step descriptors; each entry's `query` +// is the body forwarded to the upstream provider. +function firstStep(body: unknown): Record | undefined { + if (!Array.isArray(body) || body.length === 0) return undefined + const first = body[0] + return isRecord(first) ? first : undefined +} + +function extractUpstreamQuery(body: unknown): Record | undefined { + const query = firstStep(body)?.query + return isRecord(query) ? query : undefined +} + +// Each step descriptor also carries the `headers` forwarded to the upstream provider. +function extractUpstreamHeaders(body: unknown): Record | undefined { + const headers = firstStep(body)?.headers + return isRecord(headers) ? headers : undefined +} + +// Mirrors the runtime routing in provider.ts getModel. +function gatewayModel(apiId: string, gatewayToken = "test") { + const aigateway = createAiGateway({ accountId: "test", gateway: "test", apiKey: gatewayToken }) + if (apiId.startsWith("openai/")) return aigateway(createOpenAI()(apiId.slice("openai/".length))) + if (apiId.startsWith("anthropic/")) + return aigateway(createAnthropic()(apiId.slice("anthropic/".length).replaceAll(".", "-"))) + const isWorkersAi = apiId.startsWith("workers-ai/") || apiId.startsWith("@cf/") + const unified = createUnified(isWorkersAi ? { apiKey: gatewayToken } : {}) + return aigateway(unified(apiId)) +} + +async function callThroughGateway(apiId: string, providerOptions: ProviderOptions, gatewayToken = "test") { + await generateText({ model: gatewayModel(apiId, gatewayToken), prompt: "hi", providerOptions }) + return extractUpstreamQuery(captured?.outerBody) +} + +describe("cf-ai-gateway routing", () => { + test("openai/* rides the native OpenAI passthrough on the Responses API", async () => { + await callThroughGateway("openai/gpt-5.4", {}) + const step = firstStep(captured?.outerBody) + expect(step?.provider).toBe("openai") + expect(step?.endpoint).toBe("v1/responses") + const upstream = extractUpstreamQuery(captured?.outerBody) + expect(upstream?.model).toBe("gpt-5.4") + }) + + test("anthropic/* rides the native Anthropic passthrough on the Messages API", async () => { + await callThroughGateway("anthropic/claude-sonnet-4-6", {}) + const step = firstStep(captured?.outerBody) + expect(step?.provider).toBe("anthropic") + expect(step?.endpoint).toBe("v1/messages") + const upstream = extractUpstreamQuery(captured?.outerBody) + expect(upstream?.model).toBe("claude-sonnet-4-6") + }) + + test("anthropic/* with a dotted models.dev id reaches Anthropic as a dashed native slug", async () => { + // models.dev ids are dotted (claude-haiku-4.5); Anthropic's Messages API 404s unless the + // version is dashed (claude-haiku-4-5). Regression guard for the dotted-id translation. + await callThroughGateway("anthropic/claude-haiku-4.5", {}) + const step = firstStep(captured?.outerBody) + expect(step?.provider).toBe("anthropic") + expect(step?.endpoint).toBe("v1/messages") + const upstream = extractUpstreamQuery(captured?.outerBody) + expect(upstream?.model).toBe("claude-haiku-4-5") + }) + + test("workers-ai models stay on the unified /compat route", async () => { + await callThroughGateway("workers-ai/@cf/moonshotai/kimi-k2.6", {}) + const step = firstStep(captured?.outerBody) + expect(step?.provider).toBe("compat") + expect(step?.endpoint).toBe("chat/completions") + const upstream = extractUpstreamQuery(captured?.outerBody) + expect(upstream?.model).toBe("workers-ai/@cf/moonshotai/kimi-k2.6") + }) +}) + +describe("cf-ai-gateway end-to-end (regression: #24432)", () => { + test("ProviderTransform.providerOptions output puts reasoning effort on the Responses wire", async () => { + // The full chain the runtime exercises for OpenAI models: + // transform.providerOptions() -> "openai" key (npm rewritten to @ai-sdk/openai) + // -> OpenAIResponsesLanguageModel emits body.reasoning.effort + // -> ai-gateway-provider wraps the body and forwards to gateway.ai.cloudflare.com + const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), { reasoningEffort: "xhigh" }) + expect(Object.keys(opts)).toEqual(["openai"]) + expect(opts.openai.reasoningEffort).toBe("xhigh") + + const upstream = await callThroughGateway("openai/gpt-5.4", opts) + expect((upstream?.reasoning as Record | undefined)?.effort).toBe("xhigh") + }) + + test("variants() output for openai/gpt-5.4 lands xhigh on the wire", async () => { + // fromModelsDevModel resolves the native npm before computing variants, so + // OpenAI models get full Responses-flavored payloads (summary + encrypted + // reasoning include for stateless multi-turn reasoning). + const variants = ProviderTransform.variants(cfModel("openai/gpt-5.4")) + expect(variants.xhigh).toEqual({ + reasoningEffort: "xhigh", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + + const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), variants.xhigh) + const upstream = await callThroughGateway("openai/gpt-5.4", opts) + const reasoning = upstream?.reasoning as Record | undefined + expect(reasoning?.effort).toBe("xhigh") + expect(reasoning?.summary).toBe("auto") + }) + + test("reasoning effort variants for anthropic models land as native adaptive thinking", async () => { + // Mirrors the runtime catalog path: models.dev reasoning_options -> reasoningVariants + // computed on the native @ai-sdk/anthropic npm -> adaptive thinking + output_config.effort. + const model = cfModel("anthropic/claude-sonnet-4-6") + const variants = ProviderTransform.reasoningVariants( + { reasoning_options: [{ type: "effort", values: ["low", "medium", "high"] }] } as never, + model, + ) + expect(variants?.high).toMatchObject({ effort: "high" }) + + const opts = ProviderTransform.providerOptions(model, variants!.high) + expect(Object.keys(opts)).toEqual(["anthropic"]) + + const upstream = await callThroughGateway("anthropic/claude-sonnet-4-6", opts) + expect((upstream?.thinking as Record | undefined)?.type).toBe("adaptive") + expect((upstream?.output_config as Record | undefined)?.effort).toBe("high") + }) + + test("reasoning_effort still reaches the /compat wire for workers-ai models", async () => { + const model = cfModel("workers-ai/@cf/moonshotai/kimi-k2.6") + const opts = ProviderTransform.providerOptions(model, { reasoningEffort: "high" }) + expect(opts).toEqual({ openaiCompatible: { reasoningEffort: "high" } }) + + const upstream = await callThroughGateway("workers-ai/@cf/moonshotai/kimi-k2.6", opts) + expect(upstream?.reasoning_effort).toBe("high") + }) + + test("legacy buggy key 'cloudflare-ai-gateway' does NOT reach the wire (proves the bug)", async () => { + // Sanity: confirms the bug class. If a future change accidentally restores + // providerID-keyed providerOptions, this test fails before users notice. + const upstream = await callThroughGateway("workers-ai/@cf/moonshotai/kimi-k2.6", { + "cloudflare-ai-gateway": { reasoningEffort: "high" }, + }) + expect(upstream?.reasoning_effort).toBeUndefined() + }) +}) + +describe("cf-ai-gateway token scoping (regression: #32051/#32052)", () => { + test("openai passthrough does NOT forward the Cloudflare token upstream", async () => { + await callThroughGateway("openai/gpt-5.4", {}, "cf-gateway-secret") + + expect(captured?.headers["cf-aig-authorization"]).toBe("Bearer cf-gateway-secret") + // Security invariant: the Cloudflare token must never become the upstream provider's Authorization. + expect(extractUpstreamHeaders(captured?.outerBody)?.["authorization"]).toBeUndefined() + expect(JSON.stringify(captured?.outerBody)).not.toContain("cf-gateway-secret") + }) + + test("anthropic passthrough does NOT forward the Cloudflare token upstream", async () => { + await callThroughGateway("anthropic/claude-sonnet-4-6", {}, "cf-gateway-secret") + + expect(captured?.headers["cf-aig-authorization"]).toBe("Bearer cf-gateway-secret") + expect(extractUpstreamHeaders(captured?.outerBody)?.["x-api-key"]).toBeUndefined() + expect(JSON.stringify(captured?.outerBody)).not.toContain("cf-gateway-secret") + }) + + test("workers-ai models DO forward the Cloudflare token upstream", async () => { + await callThroughGateway("workers-ai/@cf/google/gemma-4-26b-a4b-it", {}, "cf-gateway-secret") + + expect(captured?.headers["cf-aig-authorization"]).toBe("Bearer cf-gateway-secret") + expect(extractUpstreamHeaders(captured?.outerBody)?.["authorization"]).toBe("Bearer cf-gateway-secret") + }) + + test("bare @cf/ Workers AI models DO forward the Cloudflare token upstream", async () => { + await callThroughGateway("@cf/meta/llama-3.1-8b-instruct", {}, "cf-gateway-secret") + + expect(captured?.headers["cf-aig-authorization"]).toBe("Bearer cf-gateway-secret") + expect(extractUpstreamHeaders(captured?.outerBody)?.["authorization"]).toBe("Bearer cf-gateway-secret") + }) +}) diff --git a/packages/opencode/test/provider/digitalocean.test.ts b/packages/opencode/test/provider/digitalocean.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..144335c73b3409f9c9ec6f7ef844f73fc4d7b9d5 --- /dev/null +++ b/packages/opencode/test/provider/digitalocean.test.ts @@ -0,0 +1,124 @@ +import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Provider } from "../../src/provider/provider" + +import { Effect } from "effect" +import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" + +const DIGITALOCEAN = ProviderV2.ID.make("digitalocean") +const it = testEffect(LayerNode.compile(Provider.node)) + +const withEnv = (values: Record, effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]] as const)) + Object.assign(process.env, values) + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }), + ) + +const withAuth = (metadata: Record | undefined, effect: Effect.Effect) => + withEnv( + { + OPENCODE_AUTH_CONTENT: JSON.stringify({ + digitalocean: { + type: "api", + key: "sk_do_test", + ...(metadata ? { metadata } : {}), + }, + }), + }, + effect, + ) + +it.instance( + "digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN", + () => + withEnv( + { DIGITALOCEAN_ACCESS_TOKEN: "test-token" }, + Effect.gen(function* () { + const provider = yield* Provider.Service + const providers = yield* provider.list() + expect(providers[DIGITALOCEAN]).toBeDefined() + expect(providers[DIGITALOCEAN].source).toBe("env") + const baseModel = Object.values(providers[DIGITALOCEAN].models)[0] + expect(baseModel.api.url).toBe("https://inference.do-ai.run/v1") + expect(baseModel.api.npm).toBe("@ai-sdk/openai-compatible") + const routerEntries = Object.keys(providers[DIGITALOCEAN].models).filter((id) => id.startsWith("router:")) + expect(routerEntries.length).toBe(0) + }), + ), + { config: {} }, +) + +it.instance( + "digitalocean provider.models surfaces cached routers from auth metadata", + () => + withAuth( + { + routers: JSON.stringify([ + { name: "my-router", uuid: "11f1499a-aaaa-bbbb-cccc-4e013e2ddde4" }, + { name: "other-router", uuid: "22f1499a-aaaa-bbbb-cccc-4e013e2ddde4" }, + ]), + routers_fetched_at: String(Date.now()), + oauth_access: "doo_v1_test", + oauth_expires: String(Date.now() + 60 * 60 * 1000), + }, + Effect.gen(function* () { + const provider = yield* Provider.Service + const providers = yield* provider.list() + const models = providers[DIGITALOCEAN].models + expect(models["router:my-router"]).toBeDefined() + expect(models["router:my-router"].api.id).toBe("router:my-router") + expect(models["router:my-router"].api.url).toBe("https://inference.do-ai.run/v1") + expect(models["router:my-router"].api.npm).toBe("@ai-sdk/openai-compatible") + expect(models["router:other-router"]).toBeDefined() + }), + ), + { config: {} }, +) + +it.instance( + "digitalocean provider.models skips refresh when oauth bearer is expired", + () => + withAuth( + { + routers: JSON.stringify([{ name: "stale-router", uuid: "stale" }]), + routers_fetched_at: "0", + oauth_access: "doo_v1_expired", + oauth_expires: "1", + }, + Effect.gen(function* () { + const provider = yield* Provider.Service + const providers = yield* provider.list() + const models = providers[DIGITALOCEAN].models + expect(models["router:stale-router"]).toBeDefined() + }), + ), + { config: {} }, +) + +it.instance( + "digitalocean provider.models passes through base models when no auth metadata", + () => + withEnv( + { DIGITALOCEAN_ACCESS_TOKEN: "test-token" }, + Effect.gen(function* () { + const provider = yield* Provider.Service + const providers = yield* provider.list() + const models = providers[DIGITALOCEAN].models + expect(Object.keys(models).length).toBeGreaterThan(0) + expect(Object.keys(models).filter((id) => id.startsWith("router:")).length).toBe(0) + }), + ), + { config: {} }, +) diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..49db2806623dc92c99376111c191f456c28b4a63 --- /dev/null +++ b/packages/opencode/test/provider/error.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { ProviderError } from "@/provider/error" + +describe("provider stream errors", () => { + test("retries provider stream errors without a code", () => { + const messages = [ + "The model is currently at capacity due to high demand. Please try again in a few minutes, or use a higher service tier for priority processing: https://docs.x.ai/developers/advanced-api-usage/priority-processing", + "The model is temporarily unavailable.", + ] + + for (const message of messages) + expect( + ProviderError.parseStreamError({ + type: "error", + error: { message }, + }), + ).toEqual({ + type: "api_error", + message, + isRetryable: true, + responseBody: JSON.stringify({ type: "error", error: { message } }), + }) + }) +}) diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..563fa025558baaffa2f2cd29cd2c94dbaed66bfd --- /dev/null +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -0,0 +1,412 @@ +export {} +// TODO: UNCOMMENT WHEN GITLAB SUPPORT IS COMPLETED +// +// +// +// import { test, expect, describe } from "bun:test" +// import path from "path" + +// import { ProviderV2 } from "@opencode-ai/core/provider" +// import { tmpdir, withTestInstance } from "../fixture/fixture" +// import { Provider } from "@/provider/provider" +// import { Env } from "../../src/env" +// import { Global } from "@opencode-ai/core/global" +// import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" + +// test("GitLab Duo: loads provider with API key from environment", async () => { +// await using tmp = await tmpdir({ +// init: async (dir) => { +// await Bun.write( +// path.join(dir, "opencode.json"), +// JSON.stringify({ +// $schema: "https://opencode.ai/config.json", +// }), +// ) +// }, +// }) +// await withTestInstance({ +// directory: tmp.path, +// init: async () => { +// Env.set("GITLAB_TOKEN", "test-gitlab-token") +// }, +// fn: async () => { +// const providers = await list() +// expect(providers[ProviderID.gitlab]).toBeDefined() +// expect(providers[ProviderID.gitlab].key).toBe("test-gitlab-token") +// }, +// }) +// }) + +// test("GitLab Duo: config instanceUrl option sets baseURL", async () => { +// await using tmp = await tmpdir({ +// init: async (dir) => { +// await Bun.write( +// path.join(dir, "opencode.json"), +// JSON.stringify({ +// $schema: "https://opencode.ai/config.json", +// provider: { +// gitlab: { +// options: { +// instanceUrl: "https://gitlab.example.com", +// }, +// }, +// }, +// }), +// ) +// }, +// }) +// await withTestInstance({ +// directory: tmp.path, +// init: async () => { +// Env.set("GITLAB_TOKEN", "test-token") +// Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com") +// }, +// fn: async () => { +// const providers = await list() +// expect(providers[ProviderID.gitlab]).toBeDefined() +// expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.example.com") +// }, +// }) +// }) + +// test("GitLab Duo: loads with OAuth token from auth.json", async () => { +// await using tmp = await tmpdir({ +// init: async (dir) => { +// await Bun.write( +// path.join(dir, "opencode.json"), +// JSON.stringify({ +// $schema: "https://opencode.ai/config.json", +// }), +// ) +// }, +// }) + +// const authPath = path.join(Global.Path.data, "auth.json") +// await Bun.write( +// authPath, +// JSON.stringify({ +// gitlab: { +// type: "oauth", +// access: "test-access-token", +// refresh: "test-refresh-token", +// expires: Date.now() + 3600000, +// }, +// }), +// ) + +// await withTestInstance({ +// directory: tmp.path, +// init: async () => { +// Env.set("GITLAB_TOKEN", "") +// }, +// fn: async () => { +// const providers = await list() +// expect(providers[ProviderID.gitlab]).toBeDefined() +// }, +// }) +// }) + +// test("GitLab Duo: loads with Personal Access Token from auth.json", async () => { +// await using tmp = await tmpdir({ +// init: async (dir) => { +// await Bun.write( +// path.join(dir, "opencode.json"), +// JSON.stringify({ +// $schema: "https://opencode.ai/config.json", +// }), +// ) +// }, +// }) + +// const authPath2 = path.join(Global.Path.data, "auth.json") +// await Bun.write( +// authPath2, +// JSON.stringify({ +// gitlab: { +// type: "api", +// key: "glpat-test-pat-token", +// }, +// }), +// ) + +// await withTestInstance({ +// directory: tmp.path, +// init: async () => { +// Env.set("GITLAB_TOKEN", "") +// }, +// fn: async () => { +// const providers = await list() +// expect(providers[ProviderID.gitlab]).toBeDefined() +// expect(providers[ProviderID.gitlab].key).toBe("glpat-test-pat-token") +// }, +// }) +// }) + +// test("GitLab Duo: supports self-hosted instance configuration", async () => { +// await using tmp = await tmpdir({ +// init: async (dir) => { +// await Bun.write( +// path.join(dir, "opencode.json"), +// JSON.stringify({ +// $schema: "https://opencode.ai/config.json", +// provider: { +// gitlab: { +// options: { +// instanceUrl: "https://gitlab.company.internal", +// apiKey: "glpat-internal-token", +// }, +// }, +// }, +// }), +// ) +// }, +// }) +// await withTestInstance({ +// directory: tmp.path, +// init: async () => { +// Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal") +// }, +// fn: async () => { +// const providers = await list() +// expect(providers[ProviderID.gitlab]).toBeDefined() +// expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.company.internal") +// }, +// }) +// }) + +// test("GitLab Duo: config apiKey takes precedence over environment variable", async () => { +// await using tmp = await tmpdir({ +// init: async (dir) => { +// await Bun.write( +// path.join(dir, "opencode.json"), +// JSON.stringify({ +// $schema: "https://opencode.ai/config.json", +// provider: { +// gitlab: { +// options: { +// apiKey: "config-token", +// }, +// }, +// }, +// }), +// ) +// }, +// }) +// await withTestInstance({ +// directory: tmp.path, +// init: async () => { +// Env.set("GITLAB_TOKEN", "env-token") +// }, +// fn: async () => { +// const providers = await list() +// expect(providers[ProviderID.gitlab]).toBeDefined() +// }, +// }) +// }) + +// test("GitLab Duo: includes context-1m beta header in aiGatewayHeaders", async () => { +// await using tmp = await tmpdir({ +// init: async (dir) => { +// await Bun.write( +// path.join(dir, "opencode.json"), +// JSON.stringify({ +// $schema: "https://opencode.ai/config.json", +// }), +// ) +// }, +// }) +// await withTestInstance({ +// directory: tmp.path, +// init: async () => { +// Env.set("GITLAB_TOKEN", "test-token") +// }, +// fn: async () => { +// const providers = await list() +// expect(providers[ProviderID.gitlab]).toBeDefined() +// expect(providers[ProviderID.gitlab].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain( +// "context-1m-2025-08-07", +// ) +// }, +// }) +// }) + +// test("GitLab Duo: supports feature flags configuration", async () => { +// await using tmp = await tmpdir({ +// init: async (dir) => { +// await Bun.write( +// path.join(dir, "opencode.json"), +// JSON.stringify({ +// $schema: "https://opencode.ai/config.json", +// provider: { +// gitlab: { +// options: { +// featureFlags: { +// duo_agent_platform_agentic_chat: true, +// duo_agent_platform: true, +// }, +// }, +// }, +// }, +// }), +// ) +// }, +// }) +// await withTestInstance({ +// directory: tmp.path, +// init: async () => { +// Env.set("GITLAB_TOKEN", "test-token") +// }, +// fn: async () => { +// const providers = await list() +// expect(providers[ProviderID.gitlab]).toBeDefined() +// expect(providers[ProviderID.gitlab].options?.featureFlags).toBeDefined() +// expect(providers[ProviderID.gitlab].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true) +// }, +// }) +// }) + +// test("GitLab Duo: has multiple agentic chat models available", async () => { +// await using tmp = await tmpdir({ +// init: async (dir) => { +// await Bun.write( +// path.join(dir, "opencode.json"), +// JSON.stringify({ +// $schema: "https://opencode.ai/config.json", +// }), +// ) +// }, +// }) +// await withTestInstance({ +// directory: tmp.path, +// init: async () => { +// Env.set("GITLAB_TOKEN", "test-token") +// }, +// fn: async () => { +// const providers = await list() +// expect(providers[ProviderID.gitlab]).toBeDefined() +// const models = Object.keys(providers[ProviderID.gitlab].models) +// expect(models.length).toBeGreaterThan(0) +// expect(models).toContain("duo-chat-haiku-4-5") +// expect(models).toContain("duo-chat-sonnet-4-5") +// expect(models).toContain("duo-chat-opus-4-5") +// }, +// }) +// }) + +// describe("GitLab Duo: workflow model routing", () => { +// test("duo-workflow-* model routes through workflowChat", async () => { +// await using tmp = await tmpdir({ +// init: async (dir) => { +// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) +// }, +// }) +// await withTestInstance({ +// directory: tmp.path, +// init: async () => { +// Env.set("GITLAB_TOKEN", "test-token") +// }, +// fn: async () => { +// const providers = await list() +// const gitlab = providers[ProviderID.gitlab] +// expect(gitlab).toBeDefined() +// gitlab.models["duo-workflow-sonnet-4-6"] = { +// id: ModelID.make("duo-workflow-sonnet-4-6"), +// providerID: ProviderID.make("gitlab"), +// name: "Agent Platform (Claude Sonnet 4.6)", +// family: "", +// api: { id: "duo-workflow-sonnet-4-6", url: "https://gitlab.com", npm: "gitlab-ai-provider" }, +// status: "active", +// headers: {}, +// options: { workflowRef: "claude_sonnet_4_6" }, +// cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, +// limit: { context: 200000, output: 64000 }, +// capabilities: { +// temperature: false, +// reasoning: true, +// attachment: true, +// toolcall: true, +// input: { text: true, audio: false, image: true, video: false, pdf: true }, +// output: { text: true, audio: false, image: false, video: false, pdf: false }, +// interleaved: false, +// }, +// release_date: "", +// variants: {}, +// } +// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6")) +// expect(model).toBeDefined() +// expect(model.options?.workflowRef).toBe("claude_sonnet_4_6") +// const language = await getLanguage(model) +// expect(language).toBeDefined() +// expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel) +// }, +// }) +// }) + +// test("duo-chat-* model routes through agenticChat (not workflow)", async () => { +// await using tmp = await tmpdir({ +// init: async (dir) => { +// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) +// }, +// }) +// await withTestInstance({ +// directory: tmp.path, +// init: async () => { +// Env.set("GITLAB_TOKEN", "test-token") +// }, +// fn: async () => { +// const providers = await list() +// expect(providers[ProviderID.gitlab]).toBeDefined() +// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) +// expect(model).toBeDefined() +// const language = await getLanguage(model) +// expect(language).toBeDefined() +// expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel) +// }, +// }) +// }) + +// test("model.options merged with provider.options in getLanguage", async () => { +// await using tmp = await tmpdir({ +// init: async (dir) => { +// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) +// }, +// }) +// await withTestInstance({ +// directory: tmp.path, +// init: async () => { +// Env.set("GITLAB_TOKEN", "test-token") +// }, +// fn: async () => { +// const providers = await list() +// const gitlab = providers[ProviderID.gitlab] +// expect(gitlab.options?.featureFlags).toBeDefined() +// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) +// expect(model).toBeDefined() +// expect(model.options).toBeDefined() +// }, +// }) +// }) +// }) + +// describe("GitLab Duo: static models", () => { +// test("static duo-chat models always present regardless of discovery", async () => { +// await using tmp = await tmpdir({ +// init: async (dir) => { +// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) +// }, +// }) +// await withTestInstance({ +// directory: tmp.path, +// init: async () => { +// Env.set("GITLAB_TOKEN", "test-token") +// }, +// fn: async () => { +// const providers = await list() +// const models = Object.keys(providers[ProviderID.gitlab].models) +// expect(models).toContain("duo-chat-haiku-4-5") +// expect(models).toContain("duo-chat-sonnet-4-5") +// expect(models).toContain("duo-chat-opus-4-5") +// }, +// }) +// }) +// }) diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..38b884dc7fc18415d5c43b3b947943c2c2955be6 --- /dev/null +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -0,0 +1,307 @@ +import { afterEach, expect } from "bun:test" +import { createServer, type Server } from "node:http" +import { streamText } from "ai" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { testProviderConfig } from "../lib/test-provider" +import { Env } from "@/env" +import { Plugin } from "@/plugin" +import { Provider } from "@/provider/provider" +import { ProviderError } from "@/provider/error" +import { MessageV2 } from "@/session/message-v2" +import { SessionRetry } from "@/session/retry" + +afterEach(async () => { + await disposeAllInstances() +}) + +const it = testEffect( + LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node, CrossSpawnSpawner.node])), +) + +it.live("headerTimeout does not abort delayed SSE body after headers arrive", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.promise(() => delayedBodyServer(1_000)), + (server) => Effect.sync(() => server.server.close()), + ) + + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model")) + const result = streamText({ + model: yield* provider.getLanguage(model), + messages: [{ role: "user", content: "hello" }], + }) + + expect(yield* Effect.promise(() => result.text)).toBe("late") + }), + { config: providerConfig(server.url, { headerTimeout: 500 }) }, + ) + }), +) + +for (const timeout of ["chunkTimeout", "headerTimeout"] as const) { + it.live(`default ${timeout} is applied at fetch without changing provider options`, () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.promise(() => delayedBodyServer(250)), + (server) => Effect.sync(() => server.server.close()), + ) + + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const configured = yield* provider.getProvider(ProviderV2.ID.make("test")) + const signals: (AbortSignal | null | undefined)[] = [] + configured.options.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + signals.push(init?.signal) + return fetch(input, init) + } + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model")) + const language = yield* provider.getLanguage(model) + yield* Effect.acquireRelease( + Effect.promise(() => + language.doStream({ prompt: [{ role: "user", content: [{ type: "text", text: "hello" }] }] }), + ), + (result) => Effect.promise(() => result.stream.cancel()), + ) + + expect(signals).toHaveLength(1) + expect(signals[0]).toBeInstanceOf(AbortSignal) + expect(configured.options[timeout]).toBeUndefined() + }), + { + config: providerConfig(server.url, { + [timeout === "chunkTimeout" ? "headerTimeout" : "chunkTimeout"]: false, + }), + }, + ) + }), + ) +} + +it.live("configured chunkTimeout raises a retryable response stream error when SSE body stalls", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.promise(() => delayedBodyServer(250)), + (server) => Effect.sync(() => server.server.close()), + ) + + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model")) + const result = streamText({ + model: yield* provider.getLanguage(model), + onError() {}, + messages: [{ role: "user", content: "hello" }], + }) + + const error = yield* Effect.promise(async () => { + try { + for await (const part of result.fullStream) { + if (part.type === "error") return part.error + } + } catch (error) { + return error + } + }) + expect(error).toBeInstanceOf(ProviderError.ResponseStreamError) + expect( + SessionRetry.retryable(MessageV2.fromError(error, { providerID: model.providerID }), model.providerID), + ).toEqual({ message: "SSE read timed out" }) + }), + { config: providerConfig(server.url, { chunkTimeout: 50 }) }, + ) + }), +) + +it.live("chunkTimeout can be disabled with false", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.promise(() => delayedBodyServer(250)), + (server) => Effect.sync(() => server.server.close()), + ) + + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const configured = yield* provider.getProvider(ProviderV2.ID.make("test")) + expect(configured.options.chunkTimeout).toBe(false) + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model")) + const result = streamText({ + model: yield* provider.getLanguage(model), + messages: [{ role: "user", content: "hello" }], + }) + + expect(yield* Effect.promise(() => result.text)).toBe("late") + }), + { config: providerConfig(server.url, { chunkTimeout: false }) }, + ) + }), +) + +it.live("headerTimeout aborts when response headers do not arrive", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.promise(() => delayedHeaderServer(250)), + (server) => Effect.sync(() => server.server.close()), + ) + + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model")) + const result = streamText({ + model: yield* provider.getLanguage(model), + onError() {}, + messages: [{ role: "user", content: "hello" }], + }) + + const errors = yield* Effect.promise(async () => { + const errors: string[] = [] + for await (const part of result.fullStream) { + if (part.type === "error") errors.push(String(part.error)) + } + return errors + }) + expect(errors.join("\n")).toContain("response headers timed out") + }), + { config: providerConfig(server.url, { headerTimeout: 50 }) }, + ) + }), +) + +it.live("headerTimeout can be disabled with false for non-OpenAI providers", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.promise(() => delayedHeaderServer(100)), + (server) => Effect.sync(() => server.server.close()), + ) + + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model")) + const result = streamText({ + model: yield* provider.getLanguage(model), + messages: [{ role: "user", content: "hello" }], + }) + + expect(yield* Effect.promise(() => result.text)).toBe("ok") + }), + { config: providerConfig(server.url, { headerTimeout: false }) }, + ) + }), +) + +it.live("OpenAI Codex header and chunk timeout defaults can be disabled by config", () => + Effect.gen(function* () { + yield* withAuthContent( + Effect.gen(function* () { + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const openai = yield* provider.getProvider(ProviderV2.ID.openai) + expect(openai.options.headerTimeout).toBe(false) + expect(openai.options.chunkTimeout).toBe(false) + }), + { config: { provider: { openai: { options: { headerTimeout: false, chunkTimeout: false } } } } }, + ) + }), + ) + }), +) + +it.live("OpenAI API auth gets default headerTimeout", () => + Effect.gen(function* () { + yield* withAuthContent( + Effect.gen(function* () { + yield* provideTmpdirInstance(() => + Effect.gen(function* () { + const provider = yield* Provider.Service + const openai = yield* provider.getProvider(ProviderV2.ID.openai) + expect(openai.options.headerTimeout).toBe(300_000) + }), + ) + }), + { openai: { type: "api", key: "sk-test" } }, + ) + }), +) + +function providerConfig(url: string, options: Record = {}) { + const config = testProviderConfig(url) + return { + ...config, + provider: { + test: { + ...config.provider.test, + options: { ...config.provider.test.options, ...options }, + }, + }, + } +} + +async function delayedHeaderServer(delay: number): Promise<{ server: Server; url: string }> { + const server = createServer((_, res) => { + setTimeout(() => { + res.writeHead(200, { "content-type": "text/event-stream" }) + res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n') + }, delay) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port") + return { server, url: `http://127.0.0.1:${address.port}` } +} + +async function delayedBodyServer(delay: number): Promise<{ server: Server; url: string }> { + const server = createServer((_, res) => { + res.writeHead(200, { "content-type": "text/event-stream" }) + res.flushHeaders() + setTimeout(() => { + res.end('data: {"choices":[{"delta":{"content":"late"}}]}\n\ndata: [DONE]\n\n') + }, delay) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port") + return { server, url: `http://127.0.0.1:${address.port}` } +} + +function withAuthContent(self: Effect.Effect, value: Record = defaultAuthContent()) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_AUTH_CONTENT + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify(value) + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_AUTH_CONTENT + else process.env.OPENCODE_AUTH_CONTENT = previous + }), + ) +} + +function defaultAuthContent() { + return { + openai: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 }, + } +} diff --git a/packages/opencode/test/provider/model-status.test.ts b/packages/opencode/test/provider/model-status.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..35a859120b266a5684b69a4c6492443f56ca5824 --- /dev/null +++ b/packages/opencode/test/provider/model-status.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ConfigProviderV1 } from "@opencode-ai/core/v1/config/provider" +import { CatalogModelStatus, ModelStatus } from "@/provider/model-status" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { Provider } from "@/provider/provider" + +describe("provider model status schemas", () => { + test("keeps catalog status separate from normalized provider status", () => { + expect(Schema.decodeUnknownSync(CatalogModelStatus)("deprecated")).toBe("deprecated") + expect(() => Schema.decodeUnknownSync(CatalogModelStatus)("active")).toThrow() + expect(Schema.decodeUnknownSync(ModelStatus)("active")).toBe("active") + }) + + test("accepts active status across public provider schemas", () => { + expect(Schema.decodeUnknownSync(ConfigProviderV1.Model)({ status: "active" }).status).toBe("active") + expect( + Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "test-model", + name: "Test Model", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }).status, + ).toBeUndefined() + expect( + Schema.decodeUnknownSync(Provider.Model)({ + id: "test-model", + providerID: "test-provider", + api: { + id: "test-model", + url: "", + npm: "@ai-sdk/openai-compatible", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", + }).status, + ).toBe("active") + }) +}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..32d4e3a39b106b1fd76a2ffef3ded479f1e56672 --- /dev/null +++ b/packages/opencode/test/provider/provider.test.ts @@ -0,0 +1,2118 @@ +import { afterEach, expect, test } from "bun:test" +import { mkdir, unlink } from "fs/promises" +import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Effect, Layer } from "effect" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Global } from "@opencode-ai/core/global" +import { disposeAllInstances, provideInstanceEffect, tmpdirScoped, TestInstance } from "../fixture/fixture" +import { markPluginDependenciesReady } from "../fixture/plugin" +import { Auth } from "@/auth" +import { Config } from "@/config/config" +import { Env } from "../../src/env" +import { Plugin } from "../../src/plugin/index" +import { Provider } from "@/provider/provider" + +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Filesystem } from "@/util/filesystem" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" +import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" + +const originalEnv = new Map() + +const rememberEnv = (k: string) => { + if (!originalEnv.has(k)) originalEnv.set(k, process.env[k]) +} + +const setProcessEnv = (k: string, v: string) => + Effect.sync(() => { + rememberEnv(k) + process.env[k] = v + }) + +const set = (k: string, v: string) => + Effect.gen(function* () { + rememberEnv(k) + process.env[k] = v + yield* Env.use.set(k, v) + }) + +const remove = (k: string) => + Effect.gen(function* () { + rememberEnv(k) + delete process.env[k] + yield* Env.use.remove(k) + }) + +afterEach(async () => { + for (const [key, value] of originalEnv) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + originalEnv.clear() + await disposeAllInstances() +}) + +const providerLayer = (flags: Partial = {}) => + LayerNode.compile( + LayerNode.group([ + Provider.node, + FSUtil.node, + Env.node, + Config.node, + Auth.node, + Plugin.node, + ModelsDev.node, + RuntimeFlags.node, + ]), + [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], + ) + +const list = Provider.use.list() + +const paid = (providers: Record }>) => { + const item = providers[ProviderV2.ID.make("opencode")] + expect(item).toBeDefined() + return Object.values(item.models).filter((model) => model.cost.input > 0).length +} + +const languageBaseURL = (language: unknown) => (language as { config: { baseURL: string } }).config.baseURL + +const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node]))) +const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true })) + +const alphaProviderConfig = { + provider: { + "custom-provider": { + name: "Custom Provider", + npm: "@ai-sdk/openai-compatible", + api: "https://api.custom.com/v1", + models: { + "active-model": { + name: "Active Model", + }, + "alpha-model": { + name: "Alpha Model", + status: "alpha" as const, + }, + }, + options: { + apiKey: "custom-key", + }, + }, + }, +} + +it.instance("provider loaded from env variable", () => + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + // Provider should retain its connection source even if custom loaders + // merge additional options. + expect(providers[ProviderV2.ID.anthropic].source).toBe("env") + expect(providers[ProviderV2.ID.anthropic].options.headers["anthropic-beta"]).toBeDefined() + }), +) + +it.instance( + "provider loaded from config with apiKey option", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + }), + { config: { provider: { anthropic: { options: { apiKey: "config-api-key" } } } } }, +) + +it.instance( + "disabled_providers excludes provider", + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic]).toBeUndefined() + }), + { config: { disabled_providers: ["anthropic"] } }, +) + +it.instance( + "enabled_providers restricts to only listed providers", + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + yield* setProcessEnv("OPENAI_API_KEY", "test-openai-key") + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + expect(providers[ProviderV2.ID.openai]).toBeUndefined() + }), + { config: { enabled_providers: ["anthropic"] } }, +) + +it.instance( + "model whitelist filters models for provider", + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + const models = Object.keys(providers[ProviderV2.ID.anthropic].models) + expect(models).toContain("claude-sonnet-4-6") + expect(models.length).toBe(1) + }), + { config: { provider: { anthropic: { whitelist: ["claude-sonnet-4-6"] } } } }, +) + +it.instance( + "model blacklist excludes specific models", + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + const models = Object.keys(providers[ProviderV2.ID.anthropic].models) + expect(models).not.toContain("claude-sonnet-4-20250514") + }), + { config: { provider: { anthropic: { blacklist: ["claude-sonnet-4-20250514"] } } } }, +) + +it.instance( + "custom model alias via config", + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + expect(providers[ProviderV2.ID.anthropic].models["my-alias"]).toBeDefined() + expect(providers[ProviderV2.ID.anthropic].models["my-alias"].name).toBe("My Custom Alias") + }), + { + config: { + provider: { + anthropic: { models: { "my-alias": { id: "claude-sonnet-4-20250514", name: "My Custom Alias" } } }, + }, + }, + }, +) + +it.instance( + "custom provider with npm package", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("custom-provider")]).toBeDefined() + expect(providers[ProviderV2.ID.make("custom-provider")].name).toBe("Custom Provider") + expect(providers[ProviderV2.ID.make("custom-provider")].models["custom-model"]).toBeDefined() + }), + { + config: { + provider: { + "custom-provider": { + name: "Custom Provider", + npm: "@ai-sdk/openai-compatible", + api: "https://api.custom.com/v1", + env: ["CUSTOM_API_KEY"], + models: { + "custom-model": { + name: "Custom Model", + tool_call: true, + limit: { context: 128000, output: 4096 }, + }, + }, + options: { apiKey: "custom-key" }, + }, + }, + }, + }, +) + +it.instance( + "filters alpha provider models by default", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("custom-provider")].models["active-model"]).toBeDefined() + expect(providers[ProviderV2.ID.make("custom-provider")].models["alpha-model"]).toBeUndefined() + }), + { config: alphaProviderConfig }, +) + +experimentalModels.instance( + "includes alpha provider models when experimental models are enabled", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("custom-provider")].models["active-model"]).toBeDefined() + expect(providers[ProviderV2.ID.make("custom-provider")].models["alpha-model"]).toBeDefined() + }), + { config: alphaProviderConfig }, +) + +it.instance( + "custom DeepSeek openai-compatible model defaults interleaved reasoning field", + Effect.gen(function* () { + const providers = yield* list + const provider = providers[ProviderV2.ID.make("custom-provider")] + expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" }) + expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" }) + expect(provider.models["deepseek-text"].capabilities.interleaved).toEqual({ field: "reasoning_text" }) + expect(provider.models["custom-reasoning"].capabilities.interleaved).toEqual({ field: "vendor_reasoning" }) + expect(provider.models["custom-model"].capabilities.interleaved).toBe(false) + expect( + providers[ProviderV2.ID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved, + ).toBe(false) + }), + { + config: { + provider: { + "custom-provider": { + name: "Custom Provider", + npm: "@ai-sdk/openai-compatible", + api: "https://api.custom.com/v1", + models: { + "deepseek-r1": { name: "DeepSeek R1" }, + "deepseek-details": { name: "DeepSeek Details", interleaved: { field: "reasoning_details" } }, + "deepseek-text": { name: "DeepSeek Text", interleaved: "reasoning_text" }, + "custom-reasoning": { name: "Custom Reasoning", interleaved: { field: "vendor_reasoning" } }, + "custom-model": { name: "Custom Model" }, + }, + options: { apiKey: "custom-key" }, + }, + "custom-anthropic-provider": { + name: "Custom Anthropic Provider", + npm: "@ai-sdk/anthropic", + api: "https://api.custom.com/v1", + models: { "deepseek-r1": { name: "DeepSeek R1" } }, + options: { apiKey: "custom-key" }, + }, + }, + }, + }, +) + +it.instance( + "env variable takes precedence, config merges options", + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "env-api-key") + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + // Config options should be merged + expect(providers[ProviderV2.ID.anthropic].options.timeout).toBe(60000) + expect(providers[ProviderV2.ID.anthropic].options.headerTimeout).toBe(10000) + expect(providers[ProviderV2.ID.anthropic].options.chunkTimeout).toBe(15000) + }), + { config: { provider: { anthropic: { options: { timeout: 60000, headerTimeout: 10000, chunkTimeout: 15000 } } } } }, +) + +it.instance("getModel returns model for valid provider/model", () => + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6")) + expect(model).toBeDefined() + expect(String(model.providerID)).toBe("anthropic") + expect(String(model.id)).toBe("claude-sonnet-4-6") + const language = yield* provider.getLanguage(model) + expect(language).toBeDefined() + }), +) + +it.instance("getModel throws ModelNotFoundError for invalid model", () => + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const exit = yield* Provider.use + .getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("nonexistent-model")) + .pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), +) + +it.instance("getModel throws ModelNotFoundError for invalid provider", () => + Effect.gen(function* () { + const exit = yield* Provider.use + .getModel(ProviderV2.ID.make("nonexistent-provider"), ModelV2.ID.make("some-model")) + .pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), +) + +// Pure synchronous unit tests — no Effect runtime needed. + +test("parseModel correctly parses provider/model string", () => { + const result = Provider.parseModel("anthropic/claude-sonnet-4") + expect(String(result.providerID)).toBe("anthropic") + expect(String(result.modelID)).toBe("claude-sonnet-4") +}) + +test("parseModel handles model IDs with slashes", () => { + const result = Provider.parseModel("openrouter/anthropic/claude-3-opus") + expect(String(result.providerID)).toBe("openrouter") + expect(String(result.modelID)).toBe("anthropic/claude-3-opus") +}) + +it.instance("defaultModel returns first available model when no config set", () => + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + const model = yield* Provider.use.defaultModel() + expect(model.providerID).toBeDefined() + expect(model.modelID).toBeDefined() + }), +) + +it.instance( + "defaultModel respects config model setting", + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + const model = yield* Provider.use.defaultModel() + expect(String(model.providerID)).toBe("anthropic") + expect(String(model.modelID)).toBe("claude-sonnet-4-20250514") + }), + { config: { model: "anthropic/claude-sonnet-4-20250514" } }, +) + +it.instance( + "defaultModel treats empty provider config as no allowlist", + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + const model = yield* Provider.use.defaultModel() + expect(model.providerID).toBeDefined() + expect(model.modelID).toBeDefined() + }), + { config: { provider: {} } }, +) + +it.instance( + "defaultModel returns a typed error when config excludes every provider", + Effect.gen(function* () { + const error = yield* Provider.use.defaultModel().pipe(Effect.flip) + expect(error).toBeInstanceOf(Provider.NoProvidersError) + expect(error._tag).toBe("ProviderNoProvidersError") + }), + { config: { enabled_providers: [] } }, +) + +it.instance( + "provider with baseURL from config", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("custom-openai")]).toBeDefined() + expect(providers[ProviderV2.ID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1") + }), + { + config: { + provider: { + "custom-openai": { + name: "Custom OpenAI", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { "gpt-4": { name: "GPT-4", tool_call: true, limit: { context: 128000, output: 4096 } } }, + options: { apiKey: "test-key", baseURL: "https://custom.openai.com/v1" }, + }, + }, + }, + }, +) + +it.instance( + "model cost defaults to zero when not specified", + Effect.gen(function* () { + const providers = yield* list + const model = providers[ProviderV2.ID.make("test-provider")].models["test-model"] + expect(model.cost.input).toBe(0) + expect(model.cost.output).toBe(0) + expect(model.cost.cache.read).toBe(0) + expect(model.cost.cache.write).toBe(0) + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { "test-model": { name: "Test Model", tool_call: true, limit: { context: 128000, output: 4096 } } }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance( + "model options are merged from existing model", + Effect.gen(function* () { + const providers = yield* list + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"] + expect(model.options.customOption).toBe("custom-value") + }), + { + config: { + provider: { + anthropic: { + options: { apiKey: "test-api-key" }, + models: { "claude-sonnet-4-6": { options: { customOption: "custom-value" } } }, + }, + }, + }, + }, +) + +it.instance( + "provider removed when all models filtered out", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic]).toBeUndefined() + }), + { config: { provider: { anthropic: { options: { apiKey: "test-api-key" }, whitelist: ["nonexistent-model"] } } } }, +) + +it.instance("closest finds model by partial match", () => + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const result = yield* Provider.use.closest(ProviderV2.ID.anthropic, ["sonnet-4"]) + expect(result).toBeDefined() + expect(String(result?.providerID)).toBe("anthropic") + expect(String(result?.modelID)).toContain("sonnet-4") + }), +) + +it.instance("closest returns undefined for nonexistent provider", () => + Effect.gen(function* () { + const result = yield* Provider.use.closest(ProviderV2.ID.make("nonexistent"), ["model"]) + expect(result).toBeUndefined() + }), +) + +it.instance( + "getModel uses realIdByKey for aliased models", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic].models["my-sonnet"]).toBeDefined() + + const model = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("my-sonnet")) + expect(model).toBeDefined() + expect(String(model.id)).toBe("my-sonnet") + expect(model.name).toBe("My Sonnet Alias") + }), + { + config: { + provider: { + anthropic: { + models: { "my-sonnet": { id: "claude-sonnet-4-20250514", name: "My Sonnet Alias" } }, + }, + }, + }, + }, +) + +it.instance( + "provider api field sets model api.url", + Effect.gen(function* () { + const providers = yield* list + // api field is stored on model.api.url, used by getSDK to set baseURL + expect(providers[ProviderV2.ID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1") + }), + { + config: { + provider: { + "custom-api": { + name: "Custom API", + npm: "@ai-sdk/openai-compatible", + api: "https://api.example.com/v1", + env: [], + models: { "model-1": { name: "Model 1", tool_call: true, limit: { context: 8000, output: 2000 } } }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance( + "explicit baseURL overrides api field", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1") + }), + { + config: { + provider: { + "custom-api": { + name: "Custom API", + npm: "@ai-sdk/openai-compatible", + api: "https://api.example.com/v1", + env: [], + models: { "model-1": { name: "Model 1", tool_call: true, limit: { context: 8000, output: 2000 } } }, + options: { apiKey: "test-key", baseURL: "https://custom.override.com/v1" }, + }, + }, + }, + }, +) + +it.instance( + "model inherits properties from existing database model", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"] + expect(model.name).toBe("Custom Name for Sonnet") + expect(model.capabilities.toolcall).toBe(true) + expect(model.capabilities.attachment).toBe(true) + expect(model.limit.context).toBeGreaterThan(0) + }), + { + config: { + provider: { anthropic: { models: { "claude-sonnet-4-6": { name: "Custom Name for Sonnet" } } } }, + }, + }, +) + +it.instance( + "model config preserves explicitly empty models.dev variants", + Effect.gen(function* () { + yield* set("OPENAI_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.openai].models["custom-gpt-chat"] + expect(model.name).toBe("Custom GPT Chat") + expect(model.variants).toEqual({}) + }), + { + config: { + provider: { + openai: { models: { "custom-gpt-chat": { id: "gpt-5-chat-latest", name: "Custom GPT Chat" } } }, + }, + }, + }, +) + +it.instance( + "model config regenerates variants when overriding the provider package", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"] + expect(model.variants?.low).toEqual({ reasoningEffort: "low" }) + expect(model.variants?.max).toBeUndefined() + }), + { + config: { + provider: { + anthropic: { + npm: "@ai-sdk/openai-compatible", + models: { "claude-sonnet-4-6": { name: "Claude via OpenAI" } }, + }, + }, + }, + }, +) + +it.instance( + "disabled_providers prevents loading even with env var", + Effect.gen(function* () { + yield* set("OPENAI_API_KEY", "test-openai-key") + const providers = yield* list + expect(providers[ProviderV2.ID.openai]).toBeUndefined() + }), + { config: { disabled_providers: ["openai"] } }, +) + +it.instance( + "enabled_providers with empty array allows no providers", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + yield* set("OPENAI_API_KEY", "test-openai-key") + const providers = yield* list + expect(Object.keys(providers).length).toBe(0) + }), + { config: { enabled_providers: [] } }, +) + +it.instance( + "whitelist and blacklist can be combined", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + const models = Object.keys(providers[ProviderV2.ID.anthropic].models) + expect(models).toContain("claude-sonnet-4-6") + expect(models).not.toContain("claude-opus-4-6") + expect(models.length).toBe(1) + }), + { + config: { + provider: { + anthropic: { + whitelist: ["claude-sonnet-4-6", "claude-opus-4-6"], + blacklist: ["claude-opus-4-6"], + }, + }, + }, + }, +) + +it.instance( + "model modalities default correctly", + Effect.gen(function* () { + const providers = yield* list + const model = providers[ProviderV2.ID.make("test-provider")].models["test-model"] + expect(model.capabilities.input.text).toBe(true) + expect(model.capabilities.output.text).toBe(true) + }), + { + config: { + provider: { + "test-provider": { + name: "Test", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { "test-model": { name: "Test Model", tool_call: true, limit: { context: 8000, output: 2000 } } }, + options: { apiKey: "test" }, + }, + }, + }, + }, +) + +it.instance( + "model with custom cost values", + Effect.gen(function* () { + const providers = yield* list + const model = providers[ProviderV2.ID.make("test-provider")].models["test-model"] + expect(model.cost.input).toBe(5) + expect(model.cost.output).toBe(15) + expect(model.cost.cache.read).toBe(2.5) + expect(model.cost.cache.write).toBe(7.5) + }), + { + config: { + provider: { + "test-provider": { + name: "Test", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + "test-model": { + name: "Test Model", + tool_call: true, + limit: { context: 8000, output: 2000 }, + cost: { input: 5, output: 15, cache_read: 2.5, cache_write: 7.5 }, + }, + }, + options: { apiKey: "test" }, + }, + }, + }, + }, +) + +it.instance("getSmallModel returns appropriate small model", () => + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic) + expect(model).toBeDefined() + expect(model?.id).toContain("haiku") + }), +) + +it.instance("getSmallModel prefers Gemini for Google Vertex", () => + Effect.gen(function* () { + yield* set("GOOGLE_VERTEX_PROJECT", "test-project") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.googleVertex) + expect(model).toBeDefined() + expect(model?.id).toContain("gemini") + }), +) + +it.instance( + "getSmallModel selects the latest model in the preferred family", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model?.id).toBe(ModelV2.ID.make("new-flash")) + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "old-flash": { family: "gemini-flash", release_date: "2025-01-01" }, + "new-flash": { family: "gemini-flash", release_date: "2026-01-01" }, + "newer-haiku": { family: "claude-haiku", release_date: "2026-06-01" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance( + "getSmallModel matches exact model families", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model?.id).toBe(ModelV2.ID.make("claude-haiku")) + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "glm-flash": { family: "glm-flash", release_date: "2026-06-01" }, + "claude-haiku": { family: "claude-haiku", release_date: "2026-01-01" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance( + "getSmallModel ignores model IDs without family metadata", + Effect.gen(function* () { + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) + expect(model).toBeUndefined() + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + models: { + "gpt-5-nano": { release_date: "2026-01-01" }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance("getSmallModel skips inferred models for Azure", () => + Effect.gen(function* () { + yield* set("AZURE_RESOURCE_NAME", "test-resource") + yield* set("AZURE_API_KEY", "test-key") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.azure) + expect(model).toBeUndefined() + }), +) + +it.instance("getSmallModel skips inferred models for Azure Cognitive Services", () => + Effect.gen(function* () { + yield* set("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "test-resource") + yield* set("AZURE_COGNITIVE_SERVICES_API_KEY", "test-key") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("azure-cognitive-services")) + expect(model).toBeUndefined() + }), +) + +it.instance( + "getSmallModel respects config small_model override", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic) + expect(model).toBeDefined() + expect(String(model?.providerID)).toBe("anthropic") + expect(String(model?.id)).toBe("claude-sonnet-4-6") + }), + { config: { small_model: "anthropic/claude-sonnet-4-6" } }, +) + +it.instance( + "getSmallModel ignores invalid config small_model", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic) + expect(model).toBeUndefined() + }), + { config: { small_model: "anthropic/not-a-real-model" } }, +) + +test("provider.sort prioritizes preferred models", () => { + const models = [ + { id: "random-model", name: "Random" }, + { id: "claude-sonnet-4-latest", name: "Claude Sonnet 4" }, + { id: "gpt-5-turbo", name: "GPT-5 Turbo" }, + { id: "other-model", name: "Other" }, + ] as any[] + + const sorted = Provider.sort(models) + expect(sorted[0].id).toContain("sonnet-4") + expect(sorted[0].id).toContain("latest") + expect(sorted[sorted.length - 1].id).not.toContain("gpt-5") + expect(sorted[sorted.length - 1].id).not.toContain("sonnet-4") +}) + +it.instance( + "multiple providers can be configured simultaneously", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-anthropic-key") + yield* set("OPENAI_API_KEY", "test-openai-key") + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + expect(providers[ProviderV2.ID.openai]).toBeDefined() + expect(providers[ProviderV2.ID.anthropic].options.timeout).toBe(30000) + expect(providers[ProviderV2.ID.openai].options.timeout).toBe(60000) + }), + { + config: { + provider: { + anthropic: { options: { timeout: 30000 } }, + openai: { options: { timeout: 60000 } }, + }, + }, + }, +) + +it.instance( + "provider with custom npm package", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("local-llm")]).toBeDefined() + expect(providers[ProviderV2.ID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible") + expect(providers[ProviderV2.ID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1") + }), + { + config: { + provider: { + "local-llm": { + name: "Local LLM", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { "llama-3": { name: "Llama 3", tool_call: true, limit: { context: 8192, output: 2048 } } }, + options: { apiKey: "not-needed", baseURL: "http://localhost:11434/v1" }, + }, + }, + }, + }, +) + +// Edge cases for model configuration + +it.instance( + "model alias name defaults to alias key when id differs", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic].models["sonnet"].name).toBe("sonnet") + }), + { + config: { + provider: { + anthropic: { + models: { sonnet: { id: "claude-sonnet-4-20250514" } }, + }, + }, + }, + }, +) + +it.instance( + "provider with multiple env var options only includes apiKey when single env", + Effect.gen(function* () { + yield* set("MULTI_ENV_KEY_1", "test-key") + const providers = yield* list + expect(providers[ProviderV2.ID.make("multi-env")]).toBeDefined() + // When multiple env options exist, key should NOT be auto-set + expect(providers[ProviderV2.ID.make("multi-env")].key).toBeUndefined() + }), + { + config: { + provider: { + "multi-env": { + name: "Multi Env Provider", + npm: "@ai-sdk/openai-compatible", + env: ["MULTI_ENV_KEY_1", "MULTI_ENV_KEY_2"], + models: { "model-1": { name: "Model 1", tool_call: true, limit: { context: 8000, output: 2000 } } }, + options: { baseURL: "https://api.example.com/v1" }, + }, + }, + }, + }, +) + +it.instance( + "provider with single env var includes apiKey automatically", + Effect.gen(function* () { + yield* set("SINGLE_ENV_KEY", "my-api-key") + const providers = yield* list + expect(providers[ProviderV2.ID.make("single-env")]).toBeDefined() + // Single env option should auto-set key + expect(providers[ProviderV2.ID.make("single-env")].key).toBe("my-api-key") + }), + { + config: { + provider: { + "single-env": { + name: "Single Env Provider", + npm: "@ai-sdk/openai-compatible", + env: ["SINGLE_ENV_KEY"], + models: { "model-1": { name: "Model 1", tool_call: true, limit: { context: 8000, output: 2000 } } }, + options: { baseURL: "https://api.example.com/v1" }, + }, + }, + }, + }, +) + +it.instance( + "model cost overrides existing cost values", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"] + expect(model.cost.input).toBe(999) + expect(model.cost.output).toBe(888) + }), + { + config: { + provider: { + anthropic: { + models: { "claude-sonnet-4-20250514": { cost: { input: 999, output: 888 } } }, + }, + }, + }, + }, +) + +it.instance( + "completely new provider not in database can be configured", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("brand-new-provider")]).toBeDefined() + expect(providers[ProviderV2.ID.make("brand-new-provider")].name).toBe("Brand New") + const model = providers[ProviderV2.ID.make("brand-new-provider")].models["new-model"] + expect(model.capabilities.reasoning).toBe(true) + expect(model.capabilities.attachment).toBe(true) + expect(model.capabilities.input.image).toBe(true) + }), + { + config: { + provider: { + "brand-new-provider": { + name: "Brand New", + npm: "@ai-sdk/openai-compatible", + env: [], + api: "https://new-api.com/v1", + models: { + "new-model": { + name: "New Model", + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + limit: { context: 32000, output: 8000 }, + modalities: { input: ["text", "image"], output: ["text"] }, + }, + }, + options: { apiKey: "new-key" }, + }, + }, + }, + }, +) + +it.instance( + "disabled_providers and enabled_providers interaction", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-anthropic") + yield* set("OPENAI_API_KEY", "test-openai") + yield* set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") + const providers = yield* list + // anthropic: in enabled, not in disabled = allowed + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + // openai: in enabled, but also in disabled = NOT allowed + expect(providers[ProviderV2.ID.openai]).toBeUndefined() + // google: not in enabled = NOT allowed (even though not disabled) + expect(providers[ProviderV2.ID.google]).toBeUndefined() + }), + { + // enabled_providers takes precedence — only these are considered + // Then disabled_providers filters from the enabled set + config: { enabled_providers: ["anthropic", "openai"], disabled_providers: ["openai"] }, + }, +) + +it.instance( + "model with tool_call false", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false) + }), + { + config: { + provider: { + "no-tools": { + name: "No Tools Provider", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { "basic-model": { name: "Basic Model", tool_call: false, limit: { context: 4000, output: 1000 } } }, + options: { apiKey: "test" }, + }, + }, + }, + }, +) + +it.instance( + "model defaults tool_call to true when not specified", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true) + }), + { + config: { + provider: { + "default-tools": { + name: "Default Tools Provider", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { model: { name: "Model", limit: { context: 4000, output: 1000 } } }, + options: { apiKey: "test" }, + }, + }, + }, + }, +) + +it.instance( + "model headers are preserved", + Effect.gen(function* () { + const providers = yield* list + const model = providers[ProviderV2.ID.make("headers-provider")].models["model"] + expect(model.headers).toEqual({ + "X-Custom-Header": "custom-value", + Authorization: "Bearer special-token", + }) + }), + { + config: { + provider: { + "headers-provider": { + name: "Headers Provider", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + model: { + name: "Model", + tool_call: true, + limit: { context: 4000, output: 1000 }, + headers: { "X-Custom-Header": "custom-value", Authorization: "Bearer special-token" }, + }, + }, + options: { apiKey: "test" }, + }, + }, + }, + }, +) + +it.instance( + "provider env fallback - second env var used if first missing", + Effect.gen(function* () { + // Only set fallback, not primary + yield* set("FALLBACK_KEY", "fallback-api-key") + const providers = yield* list + // Provider should load because fallback env var is set + expect(providers[ProviderV2.ID.make("fallback-env")]).toBeDefined() + }), + { + config: { + provider: { + "fallback-env": { + name: "Fallback Env Provider", + npm: "@ai-sdk/openai-compatible", + env: ["PRIMARY_KEY", "FALLBACK_KEY"], + models: { model: { name: "Model", tool_call: true, limit: { context: 4000, output: 1000 } } }, + options: { baseURL: "https://api.example.com" }, + }, + }, + }, + }, +) + +it.instance("getModel returns consistent results", () => + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6")) + const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6")) + expect(model1.providerID).toEqual(model2.providerID) + expect(model1.id).toEqual(model2.id) + expect(model1).toEqual(model2) + }), +) + +it.instance( + "provider name defaults to id when not in database", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("my-custom-id")].name).toBe("my-custom-id") + }), + { + config: { + provider: { + "my-custom-id": { + npm: "@ai-sdk/openai-compatible", + env: [], + models: { model: { name: "Model", tool_call: true, limit: { context: 4000, output: 1000 } } }, + options: { apiKey: "test" }, + }, + }, + }, + }, +) + +it.instance("ModelNotFoundError includes suggestions for typos", () => + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const error = yield* Provider.use + .getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonet-4")) + .pipe(Effect.flip) + expect(error.suggestions).toBeDefined() + expect((error.suggestions ?? []).length).toBeGreaterThan(0) + expect(error.message).toContain("Model not found: anthropic/claude-sonet-4") + expect(error.message).toContain("Did you mean:") + }), +) + +it.instance("ModelNotFoundError for provider includes suggestions", () => + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const error = yield* Provider.use + .getModel(ProviderV2.ID.make("antropic"), ModelV2.ID.make("claude-sonnet-4")) + .pipe(Effect.flip) + expect(error.suggestions).toBeDefined() + expect(error.suggestions).toContain("anthropic") + }), +) + +it.instance("ModelNotFoundError suggests catalog models for unloaded providers", () => + Effect.gen(function* () { + yield* remove("OPENCODE_API_KEY") + const error = yield* Provider.use + .getModel(ProviderV2.ID.opencode, ModelV2.ID.make("claude-haiku-fake-model")) + .pipe(Effect.flip) + if (!Provider.ModelNotFoundError.isInstance(error)) throw error + expect(error.suggestions ?? []).toContain("claude-haiku-4-5") + }), +) + +it.instance("getProvider returns undefined for nonexistent provider", () => + Effect.gen(function* () { + const provider = yield* Provider.Service.use((svc) => svc.getProvider(ProviderV2.ID.make("nonexistent"))) + expect(provider).toBeUndefined() + }), +) + +it.instance("getProvider returns provider info", () => + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const provider = yield* Provider.use.getProvider(ProviderV2.ID.anthropic) + expect(provider).toBeDefined() + expect(String(provider?.id)).toBe("anthropic") + }), +) + +it.instance("closest returns undefined when no partial match found", () => + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const result = yield* Provider.use.closest(ProviderV2.ID.anthropic, ["nonexistent-xyz-model"]) + expect(result).toBeUndefined() + }), +) + +it.instance("closest checks multiple query terms in order", () => + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + // First term won't match, second will + const result = yield* Provider.use.closest(ProviderV2.ID.anthropic, ["nonexistent", "haiku"]) + expect(result).toBeDefined() + expect(result?.modelID).toContain("haiku") + }), +) + +it.instance( + "model limit defaults to zero when not specified", + Effect.gen(function* () { + const providers = yield* list + const model = providers[ProviderV2.ID.make("no-limit")].models["model"] + expect(model.limit.context).toBe(0) + expect(model.limit.output).toBe(0) + }), + { + config: { + provider: { + "no-limit": { + name: "No Limit Provider", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { model: { name: "Model", tool_call: true } }, + options: { apiKey: "test" }, + }, + }, + }, + }, +) + +it.instance( + "provider options are deeply merged", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + // Custom options should be merged + expect(providers[ProviderV2.ID.anthropic].options.timeout).toBe(30000) + expect(providers[ProviderV2.ID.anthropic].options.headers["X-Custom"]).toBe("custom-value") + // anthropic custom loader adds its own headers, they should coexist + expect(providers[ProviderV2.ID.anthropic].options.headers["anthropic-beta"]).toBeDefined() + }), + { + config: { + provider: { anthropic: { options: { headers: { "X-Custom": "custom-value" }, timeout: 30000 } } }, + }, + }, +) + +it.instance( + "hosted nvidia provider adds billing origin header", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("nvidia")].options.headers).toEqual({ + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + "X-BILLING-INVOKE-ORIGIN": "OpenCode", + }) + }), + { config: { provider: { nvidia: { options: { apiKey: "test-api-key" } } } } }, +) + +it.instance( + "custom nvidia baseURL adds billing origin header", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("nvidia")].options.headers).toEqual({ + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + "X-BILLING-INVOKE-ORIGIN": "OpenCode", + }) + }), + { config: { provider: { nvidia: { options: { apiKey: "test-api-key", baseURL: "http://localhost:8000/v1" } } } } }, +) + +it.instance( + "explicit nvidia billing origin header is preserved", + Effect.gen(function* () { + const providers = yield* list + expect(providers[ProviderV2.ID.make("nvidia")].options.headers["X-BILLING-INVOKE-ORIGIN"]).toBe("CustomOrigin") + }), + { + config: { + provider: { + nvidia: { + options: { + apiKey: "test-api-key", + baseURL: "http://localhost:8000/v1", + headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, + }, + }, + }, + }, + }, +) + +it.instance( + "custom model inherits npm package from models.dev provider config", + Effect.gen(function* () { + yield* set("OPENAI_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.openai].models["my-custom-model"] + expect(model).toBeDefined() + expect(model.api.npm).toBe("@ai-sdk/openai") + }), + { + config: { + provider: { + openai: { + models: { + "my-custom-model": { + name: "My Custom Model", + tool_call: true, + limit: { context: 8000, output: 2000 }, + }, + }, + }, + }, + }, + }, +) + +it.instance( + "custom model inherits api.url from models.dev provider", + Effect.gen(function* () { + yield* set("OPENROUTER_API_KEY", "test-api-key") + const providers = yield* list + expect(providers[ProviderV2.ID.openrouter]).toBeDefined() + + // New model not in database should inherit api.url from provider + const intellect = providers[ProviderV2.ID.openrouter].models["prime-intellect/intellect-3"] + expect(intellect).toBeDefined() + expect(intellect.api.url).toBe("https://openrouter.ai/api/v1") + + // Another new model should also inherit api.url + const deepseek = providers[ProviderV2.ID.openrouter].models["deepseek/deepseek-r1-0528"] + expect(deepseek).toBeDefined() + expect(deepseek.api.url).toBe("https://openrouter.ai/api/v1") + expect(deepseek.name).toBe("DeepSeek R1") + }), + { + config: { + provider: { + openrouter: { + models: { + "prime-intellect/intellect-3": {}, + "deepseek/deepseek-r1-0528": { name: "DeepSeek R1" }, + }, + }, + }, + }, + }, +) + +test("mode options and cost are derived from the base model", () => { + const provider = { + id: "openai", + name: "OpenAI", + env: [], + npm: "@ai-sdk/openai", + api: "https://api.openai.com/v1", + models: { + "gpt-5.6-sol": { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + family: "gpt", + release_date: "2026-03-05", + attachment: true, + reasoning: true, + temperature: false, + tool_call: true, + cost: { + input: 2.5, + output: 15, + cache_read: 0.25, + context_over_200k: { + input: 5, + output: 22.5, + cache_read: 0.5, + }, + }, + limit: { + context: 1_050_000, + input: 922_000, + output: 128_000, + }, + experimental: { + modes: { + fast: { + cost: { + input: 5, + output: 30, + cache_read: 0.5, + }, + provider: { + body: { + service_tier: "priority", + }, + }, + }, + pro: { + provider: { + body: { + reasoning: { mode: "pro" }, + service_tier: "priority", + }, + }, + }, + }, + }, + }, + }, + } as unknown as ModelsDev.Provider + + const model = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-fast"] + expect(model.cost.input).toEqual(5) + expect(model.cost.output).toEqual(30) + expect(model.cost.cache.read).toEqual(0.5) + expect(model.cost.cache.write).toEqual(0) + expect(model.options["serviceTier"]).toEqual("priority") + const pro = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-pro"] + expect(pro.api.id).toEqual("gpt-5.6-sol") + expect(pro.options).toEqual({ reasoningMode: "pro", serviceTier: "priority" }) + expect(model.cost.experimentalOver200K).toEqual({ + input: 5, + output: 22.5, + cache: { read: 0.5, write: 0 }, + }) +}) + +test("models.dev normalization fills required response fields", () => { + const provider = { + id: "gateway", + name: "Gateway", + env: [], + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + family: "gpt", + interleaved: "reasoning_text", + cost: { input: 2.5, output: 15 }, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + }, + }, + } as unknown as ModelsDev.Provider + + const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4"] + expect(model.api.url).toBe("") + expect(model.capabilities.temperature).toBe(false) + expect(model.capabilities.reasoning).toBe(false) + expect(model.capabilities.attachment).toBe(false) + expect(model.capabilities.toolcall).toBe(true) + expect(model.capabilities.interleaved).toEqual({ field: "reasoning_text" }) + expect(model.release_date).toBe("") +}) + +test("models.dev reasoning options replace generated variants and unsupported toggles fall back", () => { + const provider = { + id: "reasoning", + name: "Reasoning", + env: [], + npm: "@ai-sdk/openai", + models: { + explicit: { + id: "gpt-5.4", + name: "Explicit", + reasoning: true, + reasoning_options: [{ type: "effort", values: ["low"] }], + limit: { context: 128_000, output: 64_000 }, + }, + empty: { + id: "gpt-5.4", + name: "Empty", + reasoning: true, + reasoning_options: [], + limit: { context: 128_000, output: 64_000 }, + }, + fallback: { + id: "gpt-5.4", + name: "Fallback", + reasoning: true, + reasoning_options: [{ type: "toggle" }], + limit: { context: 128_000, output: 64_000 }, + }, + override: { + id: "gemini-3-pro", + name: "Override", + reasoning: true, + reasoning_options: [{ type: "effort", values: ["high"] }], + provider: { npm: "@ai-sdk/google" }, + limit: { context: 128_000, output: 64_000 }, + experimental: { modes: { fast: {} } }, + }, + anthropicCompatible: { + id: "k3", + name: "Anthropic Compatible", + reasoning: true, + reasoning_options: [{ type: "effort", values: ["max"] }], + provider: { npm: "@ai-sdk/anthropic" }, + limit: { context: 1_048_576, output: 131_072 }, + }, + }, + } as unknown as ModelsDev.Provider + + const models = Provider.fromModelsDevProvider(provider).models + expect(models.explicit.variants).toEqual({ + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }) + expect(models.empty.variants).toEqual({}) + expect(Object.keys(models.fallback.variants ?? {})).toEqual(["none", "low", "medium", "high", "xhigh"]) + expect(models.override.variants).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }) + expect(models.anthropicCompatible.variants).toEqual({ max: { effort: "max" } }) + expect(models["gemini-3-pro-fast"].variants).toEqual(models.override.variants) +}) + +test("MERGE Gateway exposes declared effort variants without model-specific handling", () => { + const provider = { + id: "merge-gateway", + name: "MERGE Gateway", + env: ["MERGE_GATEWAY_API_KEY"], + npm: "merge-gateway-ai-sdk-provider", + models: { + "openai/gpt-5.6-sol": { + id: "openai/gpt-5.6-sol", + name: "GPT-5.6 Sol", + reasoning: true, + reasoning_options: [{ type: "effort", values: ["none", "low", "medium", "high", "xhigh", "max"] }], + limit: { context: 128_000, output: 64_000 }, + }, + }, + } as unknown as ModelsDev.Provider + + expect(Provider.fromModelsDevProvider(provider).models["openai/gpt-5.6-sol"].variants).toEqual({ + none: { reasoningEffort: "none" }, + low: { reasoningEffort: "low" }, + medium: { reasoningEffort: "medium" }, + high: { reasoningEffort: "high" }, + xhigh: { reasoningEffort: "xhigh" }, + max: { reasoningEffort: "max" }, + }) +}) + +test("public provider info omits invalid models", () => { + const provider = Provider.fromModelsDevProvider({ + id: "test", + name: "Test", + env: [], + models: { + valid: { + id: "valid", + name: "Valid", + cost: { input: 1, output: 1 }, + limit: { context: 128_000, output: 16_000 }, + }, + }, + } as unknown as ModelsDev.Provider) + provider.models.invalid = { + ...provider.models.valid, + id: ModelV2.ID.make("invalid"), + cost: { ...provider.models.valid.cost, input: Number.NaN }, + } + + const result = Provider.toPublicInfo(provider) + + expect(result.models.valid).toBeDefined() + expect(result.models.invalid).toBeUndefined() +}) + +it.instance("model variants are generated for reasoning models", () => + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + // Claude sonnet 4 has reasoning capability + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"] + expect(model.capabilities.reasoning).toBe(true) + expect(model.variants).toBeDefined() + expect(Object.keys(model.variants!).length).toBeGreaterThan(0) + }), +) + +it.instance( + "model variants can be disabled via config", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"] + expect(model.variants).toBeDefined() + expect(model.variants!["high"]).toBeUndefined() + // max variant should still exist + expect(model.variants!["max"]).toBeDefined() + }), + { + config: { + provider: { + anthropic: { + models: { "claude-sonnet-4-6": { variants: { high: { disabled: true } } } }, + }, + }, + }, + }, +) + +it.instance( + "model variants can be customized via config", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"] + expect(model.variants!["high"]).toBeDefined() + expect(model.variants!["high"].thinking.budgetTokens).toBe(20000) + }), + { + config: { + provider: { + anthropic: { + models: { + "claude-sonnet-4-6": { + variants: { high: { thinking: { type: "enabled", budgetTokens: 20000 } } }, + }, + }, + }, + }, + }, + }, +) + +it.instance( + "disabled key is stripped from variant config", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"] + expect(model.variants!["max"]).toBeDefined() + expect(model.variants!["max"].disabled).toBeUndefined() + expect(model.variants!["max"].customField).toBe("test") + }), + { + config: { + provider: { + anthropic: { + models: { + "claude-sonnet-4-6": { + variants: { max: { disabled: false, customField: "test" } }, + }, + }, + }, + }, + }, + }, +) + +it.instance( + "all variants can be disabled via config", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"] + expect(model.variants).toBeDefined() + expect(Object.keys(model.variants!).length).toBe(0) + }), + { + config: { + provider: { + anthropic: { + models: { + "claude-sonnet-4-6": { + variants: { + low: { disabled: true }, + medium: { disabled: true }, + high: { disabled: true }, + max: { disabled: true }, + }, + }, + }, + }, + }, + }, + }, +) + +it.instance( + "variant config merges with generated variants", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"] + expect(model.variants!["high"]).toBeDefined() + // Should have both the generated thinking config and the custom option + expect(model.variants!["high"].thinking).toBeDefined() + expect(model.variants!["high"].extraOption).toBe("custom-value") + }), + { + config: { + provider: { + anthropic: { + models: { + "claude-sonnet-4-6": { variants: { high: { extraOption: "custom-value" } } }, + }, + }, + }, + }, + }, +) + +it.instance( + "variants filtered in second pass for database models", + Effect.gen(function* () { + yield* set("OPENAI_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.openai].models["gpt-5"] + expect(model.variants).toBeDefined() + expect(model.variants!["high"]).toBeUndefined() + // Other variants should still exist + expect(model.variants!["medium"]).toBeDefined() + }), + { + config: { + provider: { openai: { models: { "gpt-5": { variants: { high: { disabled: true } } } } } }, + }, + }, +) + +it.instance( + "custom model with variants enabled and disabled", + Effect.gen(function* () { + const providers = yield* list + const model = providers[ProviderV2.ID.make("custom-reasoning")].models["reasoning-model"] + expect(model.variants).toBeDefined() + // Enabled variants should exist + expect(model.variants!["low"]).toBeDefined() + expect(model.variants!["low"].reasoningEffort).toBe("low") + expect(model.variants!["medium"]).toBeDefined() + expect(model.variants!["medium"].reasoningEffort).toBe("medium") + expect(model.variants!["custom"]).toBeDefined() + expect(model.variants!["custom"].reasoningEffort).toBe("custom") + expect(model.variants!["custom"].budgetTokens).toBe(5000) + // Disabled variant should not exist + expect(model.variants!["high"]).toBeUndefined() + // disabled key should be stripped from all variants + expect(model.variants!["low"].disabled).toBeUndefined() + expect(model.variants!["medium"].disabled).toBeUndefined() + expect(model.variants!["custom"].disabled).toBeUndefined() + }), + { + config: { + provider: { + "custom-reasoning": { + name: "Custom Reasoning Provider", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + "reasoning-model": { + name: "Reasoning Model", + tool_call: true, + reasoning: true, + limit: { context: 128000, output: 16000 }, + variants: { + low: { reasoningEffort: "low" }, + medium: { reasoningEffort: "medium" }, + high: { reasoningEffort: "high", disabled: true }, + custom: { reasoningEffort: "custom", budgetTokens: 5000 }, + }, + }, + }, + options: { apiKey: "test-key" }, + }, + }, + }, + }, +) + +it.instance( + "Google Vertex: retains baseURL for custom proxy", + Effect.gen(function* () { + yield* set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") + const providers = yield* list + expect(providers[ProviderV2.ID.make("vertex-proxy")]).toBeDefined() + expect(providers[ProviderV2.ID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") + }), + { + config: { + provider: { + "vertex-proxy": { + name: "Vertex Proxy", + npm: "@ai-sdk/google-vertex", + api: "https://my-proxy.com/v1", + env: ["GOOGLE_APPLICATION_CREDENTIALS"], + models: { "gemini-pro": { name: "Gemini Pro", tool_call: true } }, + options: { + project: "test-project", + location: "us-central1", + baseURL: "https://my-proxy.com/v1", + }, + }, + }, + }, + }, +) + +it.instance( + "Google Vertex: supports OpenAI compatible models", + Effect.gen(function* () { + yield* set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") + const providers = yield* list + const model = providers[ProviderV2.ID.make("vertex-openai")].models["gpt-4"] + expect(model).toBeDefined() + expect(model.api.npm).toBe("@ai-sdk/openai-compatible") + }), + { + config: { + provider: { + "vertex-openai": { + name: "Vertex OpenAI", + npm: "@ai-sdk/google-vertex", + env: ["GOOGLE_APPLICATION_CREDENTIALS"], + models: { + "gpt-4": { + name: "GPT-4", + provider: { npm: "@ai-sdk/openai-compatible", api: "https://api.openai.com/v1" }, + }, + }, + options: { project: "test-project", location: "us-central1" }, + }, + }, + }, + }, +) + +it.instance("Google Vertex: uses REP endpoint for Claude continental multi-regions", () => + Effect.gen(function* () { + yield* set("GOOGLE_CLOUD_PROJECT", "test-project") + yield* set("VERTEX_LOCATION", "eu") + const provider = yield* Provider.Service + const model = yield* provider.getModel( + ProviderV2.ID.make("google-vertex"), + ModelV2.ID.make("claude-sonnet-4-6@default"), + ) + const language = yield* provider.getLanguage(model) + expect(languageBaseURL(language)).toBe( + "https://aiplatform.eu.rep.googleapis.com/v1/projects/test-project/locations/eu/publishers/anthropic/models", + ) + }), +) + +it.instance("Google Vertex Anthropic: uses REP endpoint for continental multi-regions", () => + Effect.gen(function* () { + yield* set("GOOGLE_CLOUD_PROJECT", "test-project") + yield* set("VERTEX_LOCATION", "us") + const provider = yield* Provider.Service + const model = yield* provider.getModel( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-6@default"), + ) + const language = yield* provider.getLanguage(model) + expect(languageBaseURL(language)).toBe( + "https://aiplatform.us.rep.googleapis.com/v1/projects/test-project/locations/us/publishers/anthropic/models", + ) + }), +) + +it.instance("Google Vertex: keeps regional Claude endpoints unchanged", () => + Effect.gen(function* () { + yield* set("GOOGLE_CLOUD_PROJECT", "test-project") + yield* set("VERTEX_LOCATION", "europe-west1") + const provider = yield* Provider.Service + const model = yield* provider.getModel( + ProviderV2.ID.make("google-vertex"), + ModelV2.ID.make("claude-sonnet-4-6@default"), + ) + const language = yield* provider.getLanguage(model) + expect(languageBaseURL(language)).toBe( + "https://europe-west1-aiplatform.googleapis.com/v1/projects/test-project/locations/europe-west1/publishers/anthropic/models", + ) + }), +) + +it.instance("Google Vertex: uses REP endpoint for Gemini continental multi-regions", () => + Effect.gen(function* () { + yield* set("GOOGLE_CLOUD_PROJECT", "test-project") + yield* set("VERTEX_LOCATION", "eu") + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini-3.5-flash")) + const language = yield* provider.getLanguage(model) + expect(languageBaseURL(language)).toBe( + "https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/test-project/locations/eu/publishers/google", + ) + }), +) + +it.instance("Google Vertex: keeps regional Gemini endpoints unchanged", () => + Effect.gen(function* () { + yield* set("GOOGLE_CLOUD_PROJECT", "test-project") + yield* set("VERTEX_LOCATION", "europe-west1") + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini-3.5-flash")) + const language = yield* provider.getLanguage(model) + expect(languageBaseURL(language)).toBe( + "https://europe-west1-aiplatform.googleapis.com/v1beta1/projects/test-project/locations/europe-west1/publishers/google", + ) + }), +) + +it.instance("cloudflare-ai-gateway loads with env variables", () => + Effect.gen(function* () { + yield* set("CLOUDFLARE_ACCOUNT_ID", "test-account") + yield* set("CLOUDFLARE_GATEWAY_ID", "test-gateway") + yield* set("CLOUDFLARE_API_TOKEN", "test-token") + const providers = yield* list + expect(providers[ProviderV2.ID.make("cloudflare-ai-gateway")]).toBeDefined() + }), +) + +it.instance( + "cloudflare-ai-gateway forwards config metadata options", + Effect.gen(function* () { + yield* set("CLOUDFLARE_ACCOUNT_ID", "test-account") + yield* set("CLOUDFLARE_GATEWAY_ID", "test-gateway") + yield* set("CLOUDFLARE_API_TOKEN", "test-token") + const providers = yield* list + expect(providers[ProviderV2.ID.make("cloudflare-ai-gateway")]).toBeDefined() + expect(providers[ProviderV2.ID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ + invoked_by: "test", + project: "opencode", + }) + }), + { + config: { + provider: { "cloudflare-ai-gateway": { options: { metadata: { invoked_by: "test", project: "opencode" } } } }, + }, + }, +) + +// Tests that need plugin file setup or multi-instance flows fall back to a +// scoped tmpdir + provideInstance pattern via it.effect. + +const instanceStoreLayer = LayerNode.compile(InstanceStore.node, [ + [InstanceStore.bootstrapNode, InstanceBootstrap.node], +]) +const provideMultiInstance = (eff: Effect.Effect) => + eff.pipe(Effect.provide(instanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) + +it.effect("plugin config providers persist after instance dispose", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const configDir = path.join(dir, ".opencode") + const root = path.join(configDir, "plugin") + yield* Effect.promise(() => mkdir(root, { recursive: true })) + yield* Effect.promise(() => markPluginDependenciesReady(configDir)) + yield* Effect.promise(() => markPluginDependenciesReady(Global.Path.config)) + yield* Effect.promise(() => + Bun.write( + path.join(root, "demo-provider.ts"), + [ + "export default {", + ' id: "demo.plugin-provider",', + " server: async () => ({", + " async config(cfg) {", + " cfg.provider ??= {}", + " cfg.provider.demo = {", + ' name: "Demo Provider",', + ' npm: "@ai-sdk/openai-compatible",', + ' api: "https://example.com/v1",', + " models: {", + " chat: {", + ' name: "Demo Chat",', + " tool_call: true,", + " limit: { context: 128000, output: 4096 },", + " },", + " },", + " }", + " },", + " }),", + "}", + "", + ].join("\n"), + ), + ) + + const loadAndList = Effect.gen(function* () { + const plugin = yield* Plugin.Service + const provider = yield* Provider.Service + yield* plugin.init() + return yield* provider.list() + }).pipe(provideInstanceEffect(dir)) + + const first = yield* loadAndList + expect(first[ProviderV2.ID.make("demo")]).toBeDefined() + expect(first[ProviderV2.ID.make("demo")].models[ModelV2.ID.make("chat")]).toBeDefined() + + yield* Effect.promise(() => disposeAllInstances()) + + const second = yield* loadAndList + expect(second[ProviderV2.ID.make("demo")]).toBeDefined() + expect(second[ProviderV2.ID.make("demo")].models[ModelV2.ID.make("chat")]).toBeDefined() + }).pipe(provideMultiInstance), +) + +it.instance( + "plugin config enabled and disabled providers are honored", + Effect.gen(function* () { + const instance = yield* TestInstance + const configDir = path.join(instance.directory, ".opencode") + const root = path.join(configDir, "plugin") + yield* Effect.promise(() => mkdir(root, { recursive: true })) + yield* Effect.promise(() => markPluginDependenciesReady(configDir)) + yield* Effect.promise(() => + Bun.write( + path.join(root, "provider-filter.ts"), + [ + "export default {", + ' id: "demo.provider-filter",', + " server: async () => ({", + " async config(cfg) {", + ' cfg.enabled_providers = ["anthropic", "openai"]', + ' cfg.disabled_providers = ["openai"]', + " },", + " }),", + "}", + "", + ].join("\n"), + ), + ) + + yield* set("ANTHROPIC_API_KEY", "test-anthropic-key") + yield* set("OPENAI_API_KEY", "test-openai-key") + const providers = yield* list + expect(providers[ProviderV2.ID.anthropic]).toBeDefined() + expect(providers[ProviderV2.ID.openai]).toBeUndefined() + }), +) + +it.effect("opencode loader keeps paid models when config apiKey is present", () => + Effect.gen(function* () { + const noneDir = yield* tmpdirScoped() + const keyedDir = yield* tmpdirScoped({ + config: { provider: { opencode: { options: { apiKey: "test-key" } } } }, + }) + + const listIn = (directory: string) => + Provider.use + .list() + .pipe(provideInstanceEffect(directory)) + .pipe(Effect.provide(instanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) + + const none = paid(yield* listIn(noneDir)) + const keyedCount = paid(yield* listIn(keyedDir)) + + expect(none).toBe(0) + expect(keyedCount).toBeGreaterThan(0) + }).pipe(provideMultiInstance), +) + +it.effect("opencode loader keeps paid models when auth exists", () => + Effect.gen(function* () { + const noneDir = yield* tmpdirScoped() + const keyedDir = yield* tmpdirScoped() + + const listIn = (directory: string) => + Provider.use + .list() + .pipe(provideInstanceEffect(directory)) + .pipe(Effect.provide(instanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) + + const none = paid(yield* listIn(noneDir)) + + const authPath = path.join(Global.Path.data, "auth.json") + const original = yield* Effect.promise(() => Filesystem.readText(authPath).catch(() => undefined)) + + yield* Effect.acquireRelease( + Effect.promise(() => Filesystem.write(authPath, JSON.stringify({ opencode: { type: "api", key: "test-key" } }))), + () => + Effect.promise(async () => { + if (original !== undefined) await Filesystem.write(authPath, original) + else await unlink(authPath).catch(() => undefined) + }), + ) + + const keyedCount = paid(yield* listIn(keyedDir)) + + expect(none).toBe(0) + expect(keyedCount).toBeGreaterThan(0) + }).pipe(provideMultiInstance), +) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f3e4a10c6463c1b75d887ef1f4c08782a6ffb6b --- /dev/null +++ b/packages/opencode/test/provider/transform.test.ts @@ -0,0 +1,6211 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { ProviderTransform } from "@/provider/transform" +import { LLMRequestPrep } from "@/session/llm/request" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { generateText, jsonSchema, type ModelMessage } from "ai" +import { createAmazonBedrock, type AmazonBedrockLanguageModelOptions } from "@ai-sdk/amazon-bedrock" +import { createAnthropic } from "@ai-sdk/anthropic" +import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic" + +describe("ProviderTransform.options - setCacheKey", () => { + const sessionID = "test-session-123" + + const mockModel = { + id: "anthropic/claude-3-5-sonnet", + providerID: "anthropic", + api: { + id: "claude-3-5-sonnet-20241022", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + name: "Claude 3.5 Sonnet", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.003, + output: 0.015, + cache: { read: 0.0003, write: 0.00375 }, + }, + limit: { + context: 200000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + } as any + + test("should set promptCacheKey when providerOptions.setCacheKey is true", () => { + const result = ProviderTransform.options({ + model: mockModel, + sessionID, + providerOptions: { setCacheKey: true }, + }) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should not set promptCacheKey when providerOptions.setCacheKey is false", () => { + const result = ProviderTransform.options({ + model: mockModel, + sessionID, + providerOptions: { setCacheKey: false }, + }) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should not set promptCacheKey when providerOptions is undefined", () => { + const result = ProviderTransform.options({ + model: mockModel, + sessionID, + providerOptions: undefined, + }) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should not set promptCacheKey when providerOptions does not have setCacheKey", () => { + const result = ProviderTransform.options({ model: mockModel, sessionID, providerOptions: {} }) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should set promptCacheKey for openai provider by default", () => { + const openaiModel = { + ...mockModel, + providerID: "openai", + api: { + id: "gpt-4", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + } + const result = ProviderTransform.options({ model: openaiModel, sessionID, providerOptions: {} }) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should set promptCacheKey for the OpenAI SDK regardless of provider ID", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "custom-openai", + api: { id: "gpt-5", url: "https://example.com", npm: "@ai-sdk/openai" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should not set promptCacheKey for the OpenAI-compatible SDK by provider name", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "openai", + api: { id: "gpt-5", url: "https://example.com", npm: "@ai-sdk/openai-compatible" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should not set promptCacheKey for openai when explicitly disabled", () => { + const openaiModel = { + ...mockModel, + providerID: "openai", + api: { + id: "gpt-4", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + } + const result = ProviderTransform.options({ + model: openaiModel, + sessionID, + providerOptions: { setCacheKey: false }, + }) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should set promptCacheKey for the xAI SDK by default regardless of provider ID", () => { + const xaiModel = { + ...mockModel, + providerID: "custom-xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ model: xaiModel, sessionID, providerOptions: {} }) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should not set promptCacheKey for the xAI SDK when explicitly disabled", () => { + const xaiModel = { + ...mockModel, + providerID: "xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ + model: xaiModel, + sessionID, + providerOptions: { setCacheKey: false }, + }) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should set store=false for openai provider", () => { + const openaiModel = { + ...mockModel, + providerID: "openai", + api: { + id: "gpt-4", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + } + const result = ProviderTransform.options({ + model: openaiModel, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + }) + + test("should set store=false for xAI provider by default", () => { + const xaiModel = { + ...mockModel, + providerID: "xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ + model: xaiModel, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should set store=false for xAI SDK regardless of provider ID", () => { + const xaiModel = { + ...mockModel, + providerID: "custom-xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ + model: xaiModel, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + }) + + test("should set store=false for azure provider by default", () => { + const azureModel = { + ...mockModel, + providerID: "azure", + api: { + id: "gpt-4", + url: "https://azure.com", + npm: "@ai-sdk/azure", + }, + } + const result = ProviderTransform.options({ + model: azureModel, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should disable the Azure cache key without disabling store=false", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "azure", + api: { id: "gpt-5", url: "https://azure.com", npm: "@ai-sdk/azure" }, + }, + sessionID, + providerOptions: { setCacheKey: false }, + }) + expect(result.store).toBe(false) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should keep the Azure cache key for gpt-5.5 early return", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "azure", + api: { id: "gpt-5.5", url: "https://azure.com", npm: "@ai-sdk/azure" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + expect(result.reasoningSummary).toBe("auto") + expect(result.promptCacheKey).toBe(sessionID) + }) + + for (const npm of ["@ai-sdk/deepinfra", "@ai-sdk/cerebras"]) { + test(`should set the snake-case cache key for ${npm}`, () => { + const result = ProviderTransform.options({ + model: { ...mockModel, providerID: "custom", api: { ...mockModel.api, npm } }, + sessionID, + providerOptions: {}, + }) + expect(result.prompt_cache_key).toBe(sessionID) + expect(result.promptCacheKey).toBeUndefined() + }) + } + + test("should set promptCacheKey for the Mistral SDK", () => { + const result = ProviderTransform.options({ + model: { ...mockModel, providerID: "custom", api: { ...mockModel.api, npm: "@ai-sdk/mistral" } }, + sessionID, + providerOptions: {}, + }) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should not send an undocumented OpenRouter prompt_cache_key", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "openrouter", + api: { ...mockModel.api, npm: "@openrouter/ai-sdk-provider" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.prompt_cache_key).toBeUndefined() + }) +}) + +describe("ProviderTransform.options - zai/zhipuai thinking", () => { + const sessionID = "test-session-123" + + const createModel = (providerID: string) => + ({ + id: `${providerID}/glm-4.6`, + providerID, + api: { + id: "glm-4.6", + url: "https://open.bigmodel.cn/api/paas/v4", + npm: "@ai-sdk/openai-compatible", + }, + name: "GLM 4.6", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 128000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + }) as any + + for (const providerID of ["zai-coding-plan", "zai", "zhipuai-coding-plan", "zhipuai"]) { + test(`${providerID} should set thinking cfg`, () => { + const result = ProviderTransform.options({ + model: createModel(providerID), + sessionID, + providerOptions: {}, + }) + + expect(result.thinking).toEqual({ + type: "enabled", + clear_thinking: false, + }) + }) + } +}) + +describe("ProviderTransform.options - minimax m3 thinking", () => { + const createModel = (npm: string) => + ({ + id: "minimax/minimax-m3", + providerID: "minimax", + api: { + id: "minimax-m3", + url: "https://api.minimax.com", + npm, + }, + capabilities: { reasoning: true }, + limit: { output: 64_000 }, + }) as any + + test("explicitly enables adaptive thinking with the anthropic SDK", () => { + expect( + ProviderTransform.options({ + model: createModel("@ai-sdk/anthropic"), + sessionID: "test-session-123", + }).thinking, + ).toEqual({ type: "adaptive" }) + }) + + test("uses the native default with the openai-compatible SDK", () => { + expect( + ProviderTransform.options({ + model: createModel("@ai-sdk/openai-compatible"), + sessionID: "test-session-123", + }).thinking, + ).toBeUndefined() + }) +}) + +describe("ProviderTransform.options - google thinkingConfig gating", () => { + const sessionID = "test-session-123" + + const createGoogleModel = (reasoning: boolean, npm: "@ai-sdk/google" | "@ai-sdk/google-vertex") => + ({ + id: `${npm === "@ai-sdk/google" ? "google" : "google-vertex"}/gemini-2.0-flash`, + providerID: npm === "@ai-sdk/google" ? "google" : "google-vertex", + api: { + id: "gemini-2.0-flash", + url: npm === "@ai-sdk/google" ? "https://generativelanguage.googleapis.com" : "https://vertexai.googleapis.com", + npm, + }, + name: "Gemini 2.0 Flash", + capabilities: { + temperature: true, + reasoning, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 1_000_000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + }) as any + + test("does not set thinkingConfig for google models without reasoning capability", () => { + const result = ProviderTransform.options({ + model: createGoogleModel(false, "@ai-sdk/google"), + sessionID, + providerOptions: {}, + }) + expect(result.thinkingConfig).toBeUndefined() + }) + + test("sets thinkingConfig for google models with reasoning capability", () => { + const result = ProviderTransform.options({ + model: createGoogleModel(true, "@ai-sdk/google"), + sessionID, + providerOptions: {}, + }) + expect(result.thinkingConfig).toEqual({ + includeThoughts: true, + }) + }) + + test("does not set thinkingConfig for vertex models without reasoning capability", () => { + const result = ProviderTransform.options({ + model: createGoogleModel(false, "@ai-sdk/google-vertex"), + sessionID, + providerOptions: {}, + }) + expect(result.thinkingConfig).toBeUndefined() + }) +}) + +describe("ProviderTransform.options - gpt-5 textVerbosity", () => { + const sessionID = "test-session-123" + + const createGpt5Model = (apiId: string) => + ({ + id: `openai/${apiId}`, + providerID: "openai", + api: { + id: apiId, + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + name: apiId, + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.03, output: 0.06, cache: { read: 0.001, write: 0.002 } }, + limit: { context: 128000, output: 4096 }, + status: "active", + options: {}, + headers: {}, + }) as any + + test("gpt-5.2 should have textVerbosity set to low", () => { + const model = createGpt5Model("gpt-5.2") + const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) + expect(result.textVerbosity).toBe("low") + expect(result.include).toEqual(["reasoning.encrypted_content"]) + }) + + test("Bedrock Mantle gpt-5.5 uses OpenAI Responses defaults", () => { + const model = { + ...createGpt5Model("openai.gpt-5.5"), + id: "amazon-bedrock/openai.gpt-5.5", + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5.5", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + } + const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) + expect(result.store).toBe(false) + expect(result.reasoningEffort).toBe("medium") + expect(result.reasoningSummary).toBe("auto") + expect(result.include).toEqual(["reasoning.encrypted_content"]) + expect(result.textVerbosity).toBe("low") + }) + + test("openai-compatible gpt-5 models omit Responses-only reasoningSummary", () => { + const model = { + ...createGpt5Model("gpt-5.4"), + id: "cortecs/gpt-5.4", + providerID: "cortecs", + api: { + id: "gpt-5.4", + url: "https://api.cortecs.ai/v1", + npm: "@ai-sdk/openai-compatible", + }, + } + const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) + expect(result.reasoningEffort).toBe("medium") + expect(result.reasoningSummary).toBeUndefined() + expect(result.include).toBeUndefined() + expect(result.textVerbosity).toBeUndefined() + }) + + test("azure chat completions omit Responses-only reasoning options after variants merge", async () => { + const model = { + ...createGpt5Model("gpt-5.4"), + id: "azure/gpt-5.4", + providerID: "azure", + api: { + id: "gpt-5.4", + url: "https://azure.com", + npm: "@ai-sdk/azure", + }, + variants: { + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }, + } + const result = await Effect.runPromise( + LLMRequestPrep.prepare({ + user: { + id: "msg_user-test", + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: "azure", modelID: "gpt-5.4", variant: "high" }, + } as any, + sessionID, + model, + agent: { + name: "test", + mode: "primary", + options: {}, + permission: [], + } as any, + system: [], + messages: [{ role: "user", content: "Hello" }], + tools: { + lookup: { + description: "Look up a value", + inputSchema: jsonSchema({ type: "object", properties: {} }), + }, + }, + provider: { id: "azure", options: { useCompletionUrls: true } } as any, + auth: undefined, + plugin: { + trigger: (_name: string, _input: unknown, output: unknown) => Effect.succeed(output), + list: () => Effect.succeed([]), + init: () => Effect.void, + } as any, + flags: { outputTokenMax: 32_000, client: "test" } as any, + isWorkflow: false, + }), + ) + expect(result.params.options.reasoningEffort).toBe("high") + expect(result.params.options.reasoningSummary).toBeUndefined() + expect(result.params.options.include).toBeUndefined() + expect(result.tools.lookup.strict).toBe(false) + }) + + test("gpt-5.1 should have textVerbosity set to low", () => { + const model = createGpt5Model("gpt-5.1") + const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) + expect(result.textVerbosity).toBe("low") + }) + + test("gpt-5.2-chat-latest should NOT have textVerbosity set (only supports medium)", () => { + const model = createGpt5Model("gpt-5.2-chat-latest") + const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) + expect(result.textVerbosity).toBeUndefined() + }) + + test("gpt-5.1-chat-latest should NOT have textVerbosity set (only supports medium)", () => { + const model = createGpt5Model("gpt-5.1-chat-latest") + const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) + expect(result.textVerbosity).toBeUndefined() + }) + + test("gpt-5.2-chat should NOT have textVerbosity set", () => { + const model = createGpt5Model("gpt-5.2-chat") + const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) + expect(result.textVerbosity).toBeUndefined() + }) + + test("gpt-5-chat should NOT have textVerbosity set", () => { + const model = createGpt5Model("gpt-5-chat") + const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) + expect(result.textVerbosity).toBeUndefined() + }) + + test("gpt-5.2-codex should NOT have textVerbosity set (codex models excluded)", () => { + const model = createGpt5Model("gpt-5.2-codex") + const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) + expect(result.textVerbosity).toBeUndefined() + }) +}) + +describe("ProviderTransform.options - gpt-5 reasoningEffort", () => { + const sessionID = "test-session-123" + + const createModel = (apiId: string) => + ({ + id: `azure/${apiId}`, + providerID: "azure", + api: { + id: apiId, + url: "https://azure.com", + npm: "@ai-sdk/azure", + }, + name: apiId, + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { + text: true, + audio: false, + image: true, + video: false, + pdf: false, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: false, + }, + cost: { + input: 0.03, + output: 0.06, + cache: { read: 0.001, write: 0.002 }, + }, + limit: { + context: 128000, + output: 4096, + }, + status: "active", + options: {}, + headers: {}, + }) as any + + test("gpt-5-chat should NOT set reasoningEffort", () => { + const result = ProviderTransform.options({ + model: createModel("gpt-5-chat"), + sessionID, + providerOptions: {}, + }) + + expect(result.reasoningEffort).toBeUndefined() + }) + + test("gpt-5.5 should NOT set reasoningEffort for the completions API", () => { + const result = ProviderTransform.options({ + model: createModel("gpt-5.5"), + sessionID, + providerOptions: { useCompletionUrls: true }, + }) + + expect(result.reasoningEffort).toBeUndefined() + }) + + test("gpt-5.6 should NOT set reasoningEffort for the completions API", () => { + const result = ProviderTransform.options({ + model: createModel("gpt-5.6"), + sessionID, + providerOptions: { useCompletionUrls: true }, + }) + + expect(result.reasoningEffort).toBeUndefined() + }) + + test("gpt-5.6 should set reasoningEffort for the responses API", () => { + const result = ProviderTransform.options({ + model: createModel("gpt-5.6"), + sessionID, + providerOptions: {}, + }) + + expect(result.reasoningEffort).toBe("medium") + }) +}) + +describe("ProviderTransform.options - gateway", () => { + const sessionID = "test-session-123" + + const createModel = (id: string) => + ({ + id, + providerID: "vercel", + api: { + id, + url: "https://ai-gateway.vercel.sh/v3/ai", + npm: "@ai-sdk/gateway", + }, + name: id, + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 200_000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2024-01-01", + }) as any + + test("puts gateway defaults under gateway key", () => { + const model = createModel("anthropic/claude-sonnet-4") + const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) + expect(result).toEqual({ + gateway: { + caching: "auto", + }, + }) + }) +}) + +describe("ProviderTransform.providerOptions", () => { + const createModel = (overrides: Partial = {}) => + ({ + id: "test/test-model", + providerID: "test", + api: { + id: "test-model", + url: "https://api.test.com", + npm: "@ai-sdk/openai", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 200_000, + output: 64_000, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2024-01-01", + ...overrides, + }) as any + + test("uses sdk key for non-gateway models", () => { + const model = createModel({ + providerID: "my-bedrock", + api: { + id: "anthropic.claude-sonnet-4", + url: "https://bedrock.aws", + npm: "@ai-sdk/amazon-bedrock", + }, + }) + + expect(ProviderTransform.providerOptions(model, { cachePoint: { type: "default" } })).toEqual({ + bedrock: { cachePoint: { type: "default" } }, + }) + }) + + test("forces reasoning for custom OpenAI package models with explicit effort", () => { + const model = createModel({ + providerID: "meta", + api: { + id: "muse-spark", + url: "https://api.ai.meta.com/v1", + npm: "@ai-sdk/openai", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh", reasoningSummary: "auto" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh", reasoningSummary: "auto" }, + }) + }) + + test("forces reasoning for OpenAI package models marked reasoning-capable", () => { + expect(ProviderTransform.providerOptions(createModel(), { store: false })).toEqual({ + openai: { forceReasoning: true, store: false }, + }) + }) + + test("uses canonical sdk key for custom xAI models", () => { + const model = createModel({ + providerID: "my-xai", + api: { id: "grok-4", url: "https://api.x.ai", npm: "@ai-sdk/xai" }, + }) + + expect(ProviderTransform.providerOptions(model, { promptCacheKey: "session" })).toEqual({ + xai: { promptCacheKey: "session" }, + }) + }) + + describe("anthropic thinking block binding", () => { + const binding = { prefixMismatchBehavior: "drop_block" } + const claude = (npm: string, id: string) => + createModel({ providerID: "custom", api: { id, url: "https://example.com", npm } }) + const sdks = [ + { npm: "@ai-sdk/anthropic", key: "anthropic", option: "thinking" }, + { npm: "@ai-sdk/google-vertex/anthropic", key: "anthropic", option: "thinking" }, + { npm: "@ai-sdk/amazon-bedrock", key: "bedrock", option: "reasoningConfig" }, + ] + + test("adds blockBinding to explicit adaptive thinking on @ai-sdk/anthropic", () => { + const model = claude("@ai-sdk/anthropic", "claude-fable-5-1") + expect(ProviderTransform.providerOptions(model, { thinking: { type: "adaptive" }, effort: "high" })).toEqual({ + anthropic: { thinking: { type: "adaptive", blockBinding: binding }, effort: "high" }, + }) + }) + + test("leaves explicit enabled thinking on older models alone", () => { + const model = claude("@ai-sdk/anthropic", "claude-haiku-4-5") + expect(ProviderTransform.providerOptions(model, { thinking: { type: "enabled", budgetTokens: 4000 } })).toEqual({ + anthropic: { thinking: { type: "enabled", budgetTokens: 4000 } }, + }) + }) + + sdks.forEach((sdk) => { + describe(sdk.npm, () => { + test.each([ + "claude-fable-5-1", + "claude-fable-5.1", + "claude-5.1-fable", + "global.anthropic.claude-fable-5-1", + "us.anthropic.claude-fable-5-1-v1:0", + "claude-fable-5-1@default", + "CLAUDE-FABLE-5-1", + "claude-opus-5-1", + "claude-sonnet-5-2", + "claude-mythos-5-2", + "claude-mythos-5-10", + "claude-opus-6", + "claude-6-opus", + "claude-mythos-6-20270901", + ])("adds binding for %s", (id) => { + const model = claude(sdk.npm, id) + expect(ProviderTransform.providerOptions(model, {})).toEqual({ + [sdk.key]: { [sdk.option]: { type: "adaptive", blockBinding: binding } }, + }) + expect( + ProviderTransform.providerOptions(model, { [sdk.option]: { type: "adaptive", display: "summarized" } }), + ).toEqual({ + [sdk.key]: { [sdk.option]: { type: "adaptive", display: "summarized", blockBinding: binding } }, + }) + expect( + ProviderTransform.providerOptions(model, { [sdk.option]: { type: "enabled", budgetTokens: 4000 } }), + ).toEqual({ + [sdk.key]: { [sdk.option]: { type: "enabled", budgetTokens: 4000, blockBinding: binding } }, + }) + expect(ProviderTransform.providerOptions(model, { [sdk.option]: { type: "disabled" } })).toEqual({ + [sdk.key]: { [sdk.option]: { type: "disabled" } }, + }) + }) + + test.each([ + "claude-haiku-4-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + "claude-opus-5", + "claude-sonnet-5", + "claude-fable-5", + "claude-opus-5-0", + "claude-opus-5-20260724", + "global.anthropic.claude-opus-5", + "us.anthropic.claude-opus-5", + "claude-sonnet-5@default", + "claude-mythos-5-1", + "claude-mythos-5.1", + "claude-5.1-mythos", + "global.anthropic.claude-mythos-5-1-v1:0", + "claude-mythos-5-1@default", + "CLAUDE-MYTHOS-5-1", + "claude-future", + ])("leaves thinking unchanged for %s", (id) => { + const model = claude(sdk.npm, id) + expect(ProviderTransform.providerOptions(model, {})).toEqual({ [sdk.key]: {} }) + expect( + ProviderTransform.providerOptions(model, { [sdk.option]: { type: "adaptive", display: "summarized" } }), + ).toEqual({ [sdk.key]: { [sdk.option]: { type: "adaptive", display: "summarized" } } }) + expect( + ProviderTransform.providerOptions(model, { [sdk.option]: { type: "enabled", budgetTokens: 4000 } }), + ).toEqual({ [sdk.key]: { [sdk.option]: { type: "enabled", budgetTokens: 4000 } } }) + }) + + test.each(["claude-fable-5-1", "claude-opus-5"])("honors explicit binding controls for %s", (id) => { + const model = claude(sdk.npm, id) + const options = Object.freeze({ + [sdk.option]: Object.freeze({ type: "adaptive", blockBinding: false }), + }) + expect(ProviderTransform.providerOptions(model, options)).toEqual({ + [sdk.key]: { [sdk.option]: { type: "adaptive" } }, + }) + expect(ProviderTransform.providerOptions(model, { [sdk.option]: { blockBinding: false } })).toEqual({ + [sdk.key]: {}, + }) + const custom = { [sdk.option]: { type: "adaptive", blockBinding: { prefixMismatchBehavior: "error" } } } + expect(ProviderTransform.providerOptions(model, custom)).toEqual({ [sdk.key]: custom }) + }) + + test.each([ + ["claude-opus-5", "default"], + ["claude-opus-5", "high"], + ["claude-sonnet-5", "default"], + ["claude-sonnet-5", "high"], + ["claude-mythos-5-1", "default"], + ["claude-mythos-5-1", "high"], + ["claude-haiku-4-5", "title"], + ])("omits binding from the %s %s request body and betas", async (id, mode) => { + const requests: Request[] = [] + const capture = Object.assign( + async (...args: Parameters) => { + requests.push(new Request(...args)) + return Response.json( + sdk.key === "bedrock" + ? { + output: { message: { role: "assistant", content: [{ text: "ok" }] } }, + stopReason: "end_turn", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + } + : { + type: "message", + id: "msg_1", + model: "test-model", + role: "assistant", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }, + ) + }, + { preconnect: () => undefined }, + ) + const provider = + sdk.key === "bedrock" + ? createAmazonBedrock({ apiKey: "test-key", region: "ap-southeast-1", fetch: capture }) + : sdk.npm === "@ai-sdk/google-vertex/anthropic" + ? createVertexAnthropic({ + project: "test-project", + location: "global", + generateAuthToken: async () => "test-token", + fetch: capture, + }) + : createAnthropic({ apiKey: "test-key", fetch: capture }) + const model = claude( + sdk.npm, + sdk.key === "bedrock" + ? `global.anthropic.${id}` + : sdk.npm === "@ai-sdk/google-vertex/anthropic" + ? `${id}@default` + : id, + ) + const variants = ProviderTransform.variants(model) + const options = + mode === "title" + ? ProviderTransform.smallOptions({ ...model, variants }) + : mode === "high" + ? variants.high + : {} + await generateText({ + model: provider(model.api.id), + prompt: "hi", + maxOutputTokens: 32000, + providerOptions: ProviderTransform.providerOptions(model, options), + }) + expect(requests).toHaveLength(1) + const body = await requests[0].json() + const fields = sdk.key === "bedrock" ? (body.additionalModelRequestFields ?? {}) : body + expect(fields.thinking).toEqual( + mode === "title" + ? { type: "enabled", budget_tokens: 16000 } + : mode === "high" + ? { type: "adaptive", display: "summarized" } + : undefined, + ) + expect(fields.output_config).toEqual(mode === "high" ? { effort: "high" } : undefined) + expect(fields.anthropic_beta ?? []).not.toContain("thinking-binding-controls-2026-08-01") + expect(requests[0].headers.get("anthropic-beta")?.split(",") ?? []).not.toContain( + "thinking-binding-controls-2026-08-01", + ) + }) + }) + }) + + test("leaves disabled thinking alone", () => { + const model = claude("@ai-sdk/anthropic", "claude-fable-5-1") + expect(ProviderTransform.providerOptions(model, { thinking: { type: "disabled" } })).toEqual({ + anthropic: { thinking: { type: "disabled" } }, + }) + }) + + test("applies to vertex anthropic", () => { + const model = claude("@ai-sdk/google-vertex/anthropic", "claude-fable-5-1") + expect(ProviderTransform.providerOptions(model, { thinking: { type: "adaptive" }, effort: "max" })).toEqual({ + anthropic: { thinking: { type: "adaptive", blockBinding: binding }, effort: "max" }, + }) + }) + + test("applies to bedrock reasoningConfig", () => { + const model = claude("@ai-sdk/amazon-bedrock", "us.anthropic.claude-fable-5-1-v1:0") + expect( + ProviderTransform.providerOptions(model, { reasoningConfig: { type: "adaptive", maxReasoningEffort: "high" } }), + ).toEqual({ + bedrock: { reasoningConfig: { type: "adaptive", maxReasoningEffort: "high", blockBinding: binding } }, + }) + expect(ProviderTransform.providerOptions(model, {})).toEqual({ + bedrock: { reasoningConfig: { type: "adaptive", blockBinding: binding } }, + }) + }) + + test("does not touch bedrock non-anthropic models", () => { + const model = claude("@ai-sdk/amazon-bedrock", "amazon.nova-pro-v1:0") + expect(ProviderTransform.providerOptions(model, { reasoningConfig: { type: "enabled" } })).toEqual({ + bedrock: { reasoningConfig: { type: "enabled" } }, + }) + }) + + test("does not touch non-claude models on anthropic-compatible transports", () => { + const model = claude("@ai-sdk/anthropic", "kimi-k2-thinking") + expect(ProviderTransform.providerOptions(model, { thinking: { type: "adaptive" } })).toEqual({ + anthropic: { thinking: { type: "adaptive" } }, + }) + }) + + test("reaches the anthropic wire as block_binding plus beta header", async () => { + const model = claude("@ai-sdk/anthropic", "claude-fable-5-1") + let sent: { headers: Headers; body: any } | undefined + const provider = createAnthropic({ + apiKey: "test-key", + fetch: Object.assign( + async (...args: Parameters) => { + sent = { headers: new Headers(args[1]?.headers), body: JSON.parse(String(args[1]?.body)) } + return Response.json({ + type: "message", + id: "msg_1", + model: "claude-fable-5-1", + role: "assistant", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + input_transformations: [ + { type: "thinking_dropped", path: "messages.1.content.0", reason: "prefix_binding_mismatch" }, + ], + }) + }, + { preconnect: () => undefined }, + ), + }) + const result = await generateText({ + model: provider("claude-fable-5-1"), + prompt: "hi", + providerOptions: ProviderTransform.providerOptions(model, {}), + }) + expect(sent?.body.thinking).toEqual({ + type: "adaptive", + block_binding: { prefix_mismatch_behavior: "drop_block" }, + }) + expect(sent?.headers.get("anthropic-beta")?.split(",")).toContain("thinking-binding-controls-2026-08-01") + expect(result.providerMetadata?.anthropic?.inputTransformations).toEqual([ + { type: "thinking_dropped", path: "messages.1.content.0", reason: "prefix_binding_mismatch" }, + ]) + }) + + test("reaches the vertex anthropic wire as block_binding plus beta header", async () => { + const model = claude("@ai-sdk/google-vertex/anthropic", "claude-fable-5-1") + let sent: { url: string; headers: Headers; body: any } | undefined + const provider = createVertexAnthropic({ + project: "test-project", + location: "global", + generateAuthToken: async () => "test-token", + fetch: Object.assign( + async (...args: Parameters) => { + sent = { + url: String(args[0]), + headers: new Headers(args[1]?.headers), + body: JSON.parse(String(args[1]?.body)), + } + return Response.json({ + type: "message", + id: "msg_1", + model: "claude-fable-5-1", + role: "assistant", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }) + }, + { preconnect: () => undefined }, + ), + }) + await generateText({ + model: provider("claude-fable-5-1"), + prompt: "hi", + providerOptions: ProviderTransform.providerOptions(model, {}), + }) + // Same wire shape Anthropic's own Vertex SDK produces: rawPredict URL, model moved + // out of the body, anthropic_version added, betas carried in the anthropic-beta header. + expect(sent?.url).toBe( + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/publishers/anthropic/models/claude-fable-5-1:rawPredict", + ) + expect(sent?.body.model).toBeUndefined() + expect(sent?.body.anthropic_version).toBe("vertex-2023-10-16") + expect(sent?.body.thinking).toEqual({ + type: "adaptive", + block_binding: { prefix_mismatch_behavior: "drop_block" }, + }) + expect(sent?.headers.get("anthropic-beta")?.split(",")).toContain("thinking-binding-controls-2026-08-01") + }) + + test("reaches the bedrock wire as additionalModelRequestFields", async () => { + const model = claude("@ai-sdk/amazon-bedrock", "us.anthropic.claude-fable-5-1-v1:0") + let body: any + const provider = createAmazonBedrock({ + apiKey: "test-key", + region: "us-east-1", + fetch: Object.assign( + async (...args: Parameters) => { + body = JSON.parse(String(args[1]?.body)) + return Response.json({ + output: { message: { role: "assistant", content: [{ text: "ok" }] } }, + stopReason: "end_turn", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }) + }, + { preconnect: () => undefined }, + ), + }) + await generateText({ + model: provider("us.anthropic.claude-fable-5-1-v1:0"), + prompt: "hi", + providerOptions: ProviderTransform.providerOptions(model, { + reasoningConfig: { type: "adaptive", maxReasoningEffort: "high" }, + }), + }) + expect(body.additionalModelRequestFields.thinking).toEqual({ + type: "adaptive", + block_binding: { prefix_mismatch_behavior: "drop_block" }, + }) + expect(body.additionalModelRequestFields.anthropic_beta).toContain("thinking-binding-controls-2026-08-01") + }) + }) + + test("forces reasoning for explicit effort even when model is not marked reasoning-capable", () => { + const model = createModel({ + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("forces reasoning for Azure OpenAI models with explicit effort", () => { + const model = createModel({ + providerID: "azure", + api: { + id: "custom-gpt-5-deployment", + url: "https://azure.openai.example.com/openai/v1", + npm: "@ai-sdk/azure", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + azure: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("forces reasoning for Bedrock Mantle OpenAI models with explicit effort", () => { + const model = createModel({ + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5-custom", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("overrides forceReasoning false when reasoning should be forced", () => { + expect( + ProviderTransform.providerOptions(createModel(), { forceReasoning: false, reasoningEffort: "xhigh" }), + ).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("uses gateway model provider slug for gateway models", () => { + const model = createModel({ + providerID: "vercel", + api: { + id: "anthropic/claude-sonnet-4", + url: "https://ai-gateway.vercel.sh/v3/ai", + npm: "@ai-sdk/gateway", + }, + }) + + expect(ProviderTransform.providerOptions(model, { thinking: { type: "enabled", budgetTokens: 12_000 } })).toEqual({ + anthropic: { thinking: { type: "enabled", budgetTokens: 12_000 } }, + }) + }) + + test("falls back to gateway key when gateway api id is unscoped", () => { + const model = createModel({ + id: "anthropic/claude-sonnet-4", + providerID: "vercel", + api: { + id: "claude-sonnet-4", + url: "https://ai-gateway.vercel.sh/v3/ai", + npm: "@ai-sdk/gateway", + }, + }) + + expect(ProviderTransform.providerOptions(model, { thinking: { type: "enabled", budgetTokens: 12_000 } })).toEqual({ + gateway: { thinking: { type: "enabled", budgetTokens: 12_000 } }, + }) + }) + + test("splits gateway routing options from provider-specific options", () => { + const model = createModel({ + providerID: "vercel", + api: { + id: "anthropic/claude-sonnet-4", + url: "https://ai-gateway.vercel.sh/v3/ai", + npm: "@ai-sdk/gateway", + }, + }) + + expect( + ProviderTransform.providerOptions(model, { + gateway: { order: ["vertex", "anthropic"] }, + thinking: { type: "enabled", budgetTokens: 12_000 }, + }), + ).toEqual({ + gateway: { order: ["vertex", "anthropic"] }, + anthropic: { thinking: { type: "enabled", budgetTokens: 12_000 } }, + } as any) + }) + + test("falls back to gateway key when model id has no provider slug", () => { + const model = createModel({ + id: "claude-sonnet-4", + providerID: "vercel", + api: { + id: "claude-sonnet-4", + url: "https://ai-gateway.vercel.sh/v3/ai", + npm: "@ai-sdk/gateway", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "high" })).toEqual({ + gateway: { reasoningEffort: "high" }, + }) + }) + + test("maps amazon slug to bedrock for provider options", () => { + const model = createModel({ + providerID: "vercel", + api: { + id: "amazon/nova-2-lite", + url: "https://ai-gateway.vercel.sh/v3/ai", + npm: "@ai-sdk/gateway", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningConfig: { type: "enabled" } })).toEqual({ + bedrock: { reasoningConfig: { type: "enabled" } }, + }) + }) + + test("maps Bedrock Mantle provider options to OpenAI namespace", () => { + const model = createModel({ + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5.5", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "medium" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "medium" }, + }) + }) + + test("uses groq slug for groq models", () => { + const model = createModel({ + providerID: "vercel", + api: { + id: "groq/llama-3.3-70b-versatile", + url: "https://ai-gateway.vercel.sh/v3/ai", + npm: "@ai-sdk/gateway", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningFormat: "parsed" })).toEqual({ + groq: { reasoningFormat: "parsed" }, + }) + }) +}) + +describe("ProviderTransform.schema - gemini array items", () => { + test("adds missing items for array properties", () => { + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, + } as any + + const schema = { + type: "object", + properties: { + nodes: { type: "array" }, + edges: { type: "array", items: { type: "string" } }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.nodes.items).toBeDefined() + expect(result.properties.edges.items.type).toBe("string") + }) +}) + +describe("ProviderTransform.schema - gemini nested array items", () => { + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, + } as any + + test("adds type to 2D array with empty inner items", () => { + const schema = { + type: "object", + properties: { + values: { + type: "array", + items: { + type: "array", + items: {}, // Empty items object + }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + // Inner items should have a default type + expect(result.properties.values.items.items.type).toBe("string") + }) + + test("adds items and type to 2D array with missing inner items", () => { + const schema = { + type: "object", + properties: { + data: { + type: "array", + items: { type: "array" }, // No items at all + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.data.items.items).toBeDefined() + expect(result.properties.data.items.items.type).toBe("string") + }) + + test("handles deeply nested arrays (3D)", () => { + const schema = { + type: "object", + properties: { + matrix: { + type: "array", + items: { + type: "array", + items: { + type: "array", + // No items + }, + }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.matrix.items.items.items).toBeDefined() + expect(result.properties.matrix.items.items.items.type).toBe("string") + }) + + test("preserves existing item types in nested arrays", () => { + const schema = { + type: "object", + properties: { + numbers: { + type: "array", + items: { + type: "array", + items: { type: "number" }, // Has explicit type + }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + // Should preserve the explicit type + expect(result.properties.numbers.items.items.type).toBe("number") + }) + + test("handles mixed nested structures with objects and arrays", () => { + const schema = { + type: "object", + properties: { + spreadsheetData: { + type: "object", + properties: { + rows: { + type: "array", + items: { + type: "array", + items: {}, // Empty items + }, + }, + }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.spreadsheetData.properties.rows.items.items.type).toBe("string") + }) +}) + +describe("ProviderTransform.schema - gemini type arrays", () => { + // Mirrors @ai-sdk/google's convertJSONSchemaToOpenAPISchema: JSON Schema type + // arrays (e.g. `["number","string"]`, common in MCP tool schemas) become an + // `anyOf` of single-type schemas, with `null` lifted into `nullable`. Plain + // @ai-sdk/google rewrites these, but OpenAI-compatible transports such as + // GitHub Copilot (proxying to Gemini) forward them verbatim and the backend + // rejects the array form. + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, + } as any + + test("splits a multi-type array into anyOf and drops the type array", () => { + const schema = { + type: "object", + properties: { + status: { type: ["number", "string"], description: "status filter" }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.status.type).toBeUndefined() + expect(result.properties.status.anyOf).toEqual([{ type: "number" }, { type: "string" }]) + expect(result.properties.status.nullable).toBeUndefined() + // Sibling keywords stay alongside the generated anyOf. + expect(result.properties.status.description).toBe("status filter") + }) + + test("lifts null into nullable for a nullable type array", () => { + const schema = { + type: "object", + properties: { + maybe: { type: ["string", "null"], description: "nullable string" }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.maybe.type).toBeUndefined() + expect(result.properties.maybe.anyOf).toEqual([{ type: "string" }]) + expect(result.properties.maybe.nullable).toBe(true) + }) + + test("collapses an all-null type array to type null", () => { + const schema = { + type: "object", + properties: { + nothing: { type: ["null"] }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.nothing.type).toBe("null") + expect(result.properties.nothing.anyOf).toBeUndefined() + }) + + test("rewrites type arrays for gemini served through github-copilot", () => { + const copilotGeminiModel = { + providerID: "github-copilot", + api: { + id: "gemini-3.5-flash", + npm: "@ai-sdk/github-copilot", + }, + } as any + + const schema = { + type: "object", + properties: { + hook_id: { type: "number", description: "ID of the webhook" }, + status: { type: ["number", "string"], description: "Filter by response status code" }, + }, + required: ["hook_id"], + additionalProperties: false, + } as any + + const result = ProviderTransform.schema(copilotGeminiModel, schema) as any + + expect(result.properties.status.anyOf).toEqual([{ type: "number" }, { type: "string" }]) + expect(result.properties.status.type).toBeUndefined() + expect(result.properties.hook_id.type).toBe("number") + }) +}) + +describe("ProviderTransform.schema - gemini combiner nodes", () => { + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, + } as any + + const walk = (node: any, cb: (node: any, path: (string | number)[]) => void, path: (string | number)[] = []) => { + if (node === null || typeof node !== "object") { + return + } + if (Array.isArray(node)) { + node.forEach((item, i) => walk(item, cb, [...path, i])) + return + } + cb(node, path) + Object.entries(node).forEach(([key, value]) => walk(value, cb, [...path, key])) + } + + test("keeps edits.items.anyOf without adding type", () => { + const schema = { + type: "object", + properties: { + edits: { + type: "array", + items: { + anyOf: [ + { + type: "object", + properties: { + old_string: { type: "string" }, + new_string: { type: "string" }, + }, + required: ["old_string", "new_string"], + }, + { + type: "object", + properties: { + old_string: { type: "string" }, + new_string: { type: "string" }, + replace_all: { type: "boolean" }, + }, + required: ["old_string", "new_string"], + }, + ], + }, + }, + }, + required: ["edits"], + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(Array.isArray(result.properties.edits.items.anyOf)).toBe(true) + expect(result.properties.edits.items.type).toBeUndefined() + }) + + test("does not add sibling keys to combiner nodes during sanitize", () => { + const schema = { + type: "object", + properties: { + edits: { + type: "array", + items: { + anyOf: [{ type: "string" }, { type: "number" }], + }, + }, + value: { + oneOf: [{ type: "string" }, { type: "boolean" }], + }, + meta: { + allOf: [ + { + type: "object", + properties: { a: { type: "string" } }, + }, + { + type: "object", + properties: { b: { type: "string" } }, + }, + ], + }, + }, + } as any + const input = JSON.parse(JSON.stringify(schema)) + const result = ProviderTransform.schema(geminiModel, schema) as any + + walk(result, (node, path) => { + const hasCombiner = Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf) + if (!hasCombiner) { + return + } + const before = path.reduce((acc: any, key) => acc?.[key], input) + const added = Object.keys(node).filter((key) => !(key in before)) + expect(added).toEqual([]) + }) + }) +}) + +describe("ProviderTransform.schema - gemini non-object properties removal", () => { + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, + } as any + + test("removes properties from non-object types", () => { + const schema = { + type: "object", + properties: { + data: { + type: "string", + properties: { invalid: { type: "string" } }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.data.type).toBe("string") + expect(result.properties.data.properties).toBeUndefined() + }) + + test("removes required from non-object types", () => { + const schema = { + type: "object", + properties: { + data: { + type: "array", + items: { type: "string" }, + required: ["invalid"], + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.data.type).toBe("array") + expect(result.properties.data.required).toBeUndefined() + }) + + test("removes properties and required from nested non-object types", () => { + const schema = { + type: "object", + properties: { + outer: { + type: "object", + properties: { + inner: { + type: "number", + properties: { bad: { type: "string" } }, + required: ["bad"], + }, + }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.outer.properties.inner.type).toBe("number") + expect(result.properties.outer.properties.inner.properties).toBeUndefined() + expect(result.properties.outer.properties.inner.required).toBeUndefined() + }) + + test("keeps properties and required on object types", () => { + const schema = { + type: "object", + properties: { + data: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.data.type).toBe("object") + expect(result.properties.data.properties).toBeDefined() + expect(result.properties.data.required).toEqual(["name"]) + }) + + test("does not affect non-gemini providers", () => { + const openaiModel = { + providerID: "openai", + api: { + id: "gpt-4", + }, + } as any + + const schema = { + type: "object", + properties: { + data: { + type: "string", + properties: { invalid: { type: "string" } }, + }, + }, + } as any + + const result = ProviderTransform.schema(openaiModel, schema) as any + + expect(result.properties.data.properties).toBeDefined() + }) +}) + +describe("ProviderTransform.schema - openai supported schema subset", () => { + const openaiModel = { + providerID: "openai", + api: { + id: "gpt-4.1", + npm: "@ai-sdk/openai", + }, + } as any + + test("removes unsupported JSON Schema keywords recursively", () => { + const result = ProviderTransform.schema(openaiModel, { + $schema: "https://json-schema.org/draft/2020-12/schema", + title: "Search", + type: "object", + properties: { + query: { + type: "string", + description: "Search query", + format: "uri", + pattern: "^https://", + minLength: 1, + maxLength: 100, + default: "https://example.com", + }, + count: { + type: "integer", + minimum: 1, + maximum: 10, + multipleOf: 1, + }, + createdAt: { + format: "date-time", + }, + mode: { + const: "fast", + }, + tags: { + type: "array", + minItems: 1, + maxItems: 3, + uniqueItems: true, + }, + tuple: { + type: "array", + items: [ + { type: "number", minimum: 0 }, + { type: "string", pattern: "^ok$" }, + ], + }, + metadata: { + type: "object", + patternProperties: { + "^x-": { type: "string" }, + }, + additionalProperties: { + type: "string", + pattern: "^safe$", + }, + }, + }, + patternProperties: { + "^extra": { type: "string" }, + }, + required: ["query"], + additionalProperties: false, + } as any) as any + + expect(result).toEqual({ + type: "object", + properties: { + query: { + type: "string", + description: "Search query", + }, + count: { + type: "integer", + }, + createdAt: { + type: "string", + }, + mode: { + enum: ["fast"], + type: "string", + }, + tags: { + type: "array", + items: { type: "string" }, + }, + tuple: { + type: "array", + items: [{ type: "number" }, { type: "string" }], + }, + metadata: { + type: "object", + properties: {}, + additionalProperties: { + type: "string", + }, + }, + }, + required: ["query"], + additionalProperties: false, + }) + }) + + test("keeps local references and sanitizes definitions", () => { + const result = ProviderTransform.schema(openaiModel, { + type: "object", + properties: { + value: { + $ref: "#/$defs/Value", + description: "Referenced value", + examples: ["ignored"], + }, + }, + $defs: { + Value: { + type: "string", + pattern: "^value$", + description: "Definition description", + }, + Unused: { + type: "number", + minimum: 0, + }, + }, + } as any) as any + + expect(result.properties.value).toEqual({ + $ref: "#/$defs/Value", + description: "Referenced value", + }) + expect(result.$defs).toEqual({ + Value: { + type: "string", + description: "Definition description", + }, + Unused: { + type: "number", + }, + }) + }) + + test("does not sanitize non-openai providers", () => { + const result = ProviderTransform.schema( + { + providerID: "anthropic", + api: { + id: "claude-sonnet-4", + npm: "@ai-sdk/anthropic", + }, + } as any, + { + type: "object", + properties: { + query: { + type: "string", + pattern: "^https://", + }, + }, + } as any, + ) as any + + expect(result.properties.query.pattern).toBe("^https://") + }) + + test.each([ + ["opencode", "@ai-sdk/openai"], + ["custom-openai-compatible", "@ai-sdk/openai"], + ["azure", "@ai-sdk/azure"], + ])("sanitizes %s models using %s", (providerID, npm) => { + expect( + ProviderTransform.schema( + { + providerID, + api: { + id: "custom-model", + npm, + }, + } as any, + { + type: "object", + properties: { + query: { + type: "string", + pattern: "^https://", + }, + }, + } as any, + ), + ).toEqual({ + type: "object", + properties: { + query: { + type: "string", + }, + }, + }) + }) +}) + +describe("ProviderTransform.schema - moonshot $ref siblings", () => { + const moonshotModel = { + providerID: "moonshotai", + api: { + id: "kimi-k2", + }, + } as any + + test("removes sibling descriptions from referenced tool parameter schemas", () => { + const schema = { + type: "object", + properties: { + deviceType: { + description: "Optional. The type of device that captured the screenshot, e.g. mobile or desktop.", + enum: ["DEVICE_TYPE_UNSPECIFIED", "MOBILE", "DESKTOP", "TABLET", "AGNOSTIC"], + type: "string", + }, + modelId: { + description: "Optional. The model to use for generation.", + enum: ["MODEL_ID_UNSPECIFIED", "GEMINI_3_PRO", "GEMINI_3_FLASH", "GEMINI_3_1_PRO"], + type: "string", + }, + projectId: { + description: "Required. The project ID of screens to generate variants for.", + type: "string", + }, + prompt: { + description: "Required. The input text used to generate the variants.", + type: "string", + }, + selectedScreenIds: { + description: "Required. The screen ids of screen to generate variants for.", + items: { + type: "string", + }, + type: "array", + }, + variantOptions: { + $ref: "#/$defs/VariantOptions", + description: + "Required. The variant options for generation, including the number of variants, creative range, and aspects to focus on.", + }, + }, + required: ["projectId", "selectedScreenIds", "prompt", "variantOptions"], + $defs: { + VariantOptions: { + description: + "Configuration options for design variant generation. This message captures all parameters used to generate variants, allowing the configuration to be stored, replayed, or analyzed.", + properties: { + aspects: { + description: "Optional. Specific aspects to focus on. If empty, all aspects may be varied.", + items: { + enum: ["VARIANT_ASPECT_UNSPECIFIED", "LAYOUT", "COLOR_SCHEME", "IMAGES", "TEXT_FONT", "TEXT_CONTENT"], + type: "string", + }, + type: "array", + }, + creativeRange: { + description: "Optional. Creative range for variations. Default: EXPLORE", + enum: ["CREATIVE_RANGE_UNSPECIFIED", "REFINE", "EXPLORE", "REIMAGINE"], + type: "string", + }, + variantCount: { + description: "Optional. Number of variants to generate (1-5). Default: 3", + format: "int32", + type: "integer", + }, + }, + type: "object", + }, + }, + description: "Request message for GenerateVariants.", + additionalProperties: false, + } as any + + const result = ProviderTransform.schema(moonshotModel, schema) as any + + expect(result.properties.variantOptions).toEqual({ + $ref: "#/$defs/VariantOptions", + }) + expect(result.$defs.VariantOptions.description).toBe(schema.$defs.VariantOptions.description) + }) + + test("also runs for kimi models outside the moonshot provider", () => { + const result = ProviderTransform.schema( + { + providerID: "openrouter", + name: "Kimi K2", + api: { + id: "moonshotai/kimi-k2", + }, + } as any, + { + type: "object", + properties: { + value: { + $ref: "#/$defs/Value", + description: "Moonshot rejects this sibling after ref expansion.", + }, + }, + $defs: { + Value: { + description: "Referenced schema description stays here.", + type: "object", + }, + }, + } as any, + ) as any + + expect(result.properties.value).toEqual({ + $ref: "#/$defs/Value", + }) + }) + + test("converts tuple-style array items to a single item schema", () => { + const result = ProviderTransform.schema(moonshotModel, { + type: "object", + properties: { + codeSpec: { + type: "object", + properties: { + accessibility: { + type: "object", + properties: { + renderedSize: { + description: "Rendered size [width, height] in px", + type: "array", + items: [{ type: "number" }, { type: "number" }], + minItems: 2, + maxItems: 2, + }, + }, + }, + }, + }, + }, + } as any) as any + + expect(result.properties.codeSpec.properties.accessibility.properties.renderedSize.items).toEqual({ + type: "number", + }) + }) +}) + +describe("ProviderTransform.message - Mistral tool call IDs", () => { + test.each(["codestral-latest", "pixtral-large-latest", "open-mixtral-8x22b"])( + "normalizes IDs for custom OpenAI-compatible %s models", + (id) => { + const result = ProviderTransform.message( + [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "toolu_01CBhTTz95qkd9LJMdC9sf8t", + toolName: "read", + input: { filePath: "/tmp/test" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "toolu_01CBhTTz95qkd9LJMdC9sf8t", + toolName: "read", + output: { type: "text", value: "test" }, + }, + ], + }, + ] as any, + { + id: `custom/${id}`, + providerID: "custom", + api: { + id, + url: "https://example.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + } as any, + {}, + ) + + expect(result).toMatchObject([ + { role: "assistant", content: [{ type: "tool-call", toolCallId: "toolu01CB" }] }, + { role: "tool", content: [{ type: "tool-result", toolCallId: "toolu01CB" }] }, + ]) + }, + ) +}) + +describe("ProviderTransform.message - DeepSeek reasoning content", () => { + test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Let me think about this..." }, + { + type: "tool-call", + toolCallId: "test", + toolName: "bash", + input: { command: "echo hello" }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message( + msgs, + { + id: ModelV2.ID.make("deepseek/deepseek-chat"), + providerID: ProviderV2.ID.make("deepseek"), + api: { + id: "deepseek-chat", + url: "https://api.deepseek.com", + npm: "@ai-sdk/openai-compatible", + }, + name: "DeepSeek Chat", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: { + field: "reasoning_content", + }, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 128000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2023-04-01", + }, + {}, + ) + + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { + type: "tool-call", + toolCallId: "test", + toolName: "bash", + input: { command: "echo hello" }, + }, + ]) + expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Let me think about this...") + }) + + test("Non-DeepSeek providers leave reasoning content unchanged", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Should not be processed" }, + { type: "text", text: "Answer" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message( + msgs, + { + id: ModelV2.ID.make("openai/gpt-4"), + providerID: ProviderV2.ID.make("openai"), + api: { + id: "gpt-4", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + name: "GPT-4", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.03, + output: 0.06, + cache: { read: 0.001, write: 0.002 }, + }, + limit: { + context: 128000, + output: 4096, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2023-04-01", + }, + {}, + ) + + expect(result[0].content).toEqual([ + { type: "reasoning", text: "Should not be processed" }, + { type: "text", text: "Answer" }, + ]) + expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined() + }) +}) + +describe("ProviderTransform.message - surrogate sanitization", () => { + const model = { + id: "test/test-model", + providerID: "test", + api: { + id: "test-model", + url: "https://api.test.com", + npm: "@ai-sdk/openai-compatible", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + } as any + + test("replaces lone surrogates in model-visible text", () => { + const lone = "\uD83D" + const valid = "🚀" + const sanitized = "�" + const text = (label: string) => `${label} ${lone} and ${valid}` + const expected = (label: string) => `${label} ${sanitized} and ${valid}` + const msgs = [ + { role: "system", content: text("system") }, + { role: "user", content: text("user string") }, + { + role: "user", + content: [ + { type: "text", text: text("user text") }, + { type: "image", image: "data:image/png;base64,abcd" }, + ], + }, + { role: "assistant", content: text("assistant string") }, + { + role: "assistant", + content: [ + { type: "text", text: text("assistant text") }, + { type: "reasoning", text: text("assistant reasoning") }, + { type: "tool-call", toolCallId: "call-1", toolName: "Read", input: { filePath: ".opencode/tool/emoji.ts" } }, + { + type: "tool-result", + toolCallId: "call-2", + toolName: "Read", + output: { type: "text", value: text("assistant tool text") }, + }, + { + type: "tool-result", + toolCallId: "call-3", + toolName: "Read", + output: { type: "error-text", value: text("assistant tool error") }, + }, + { + type: "tool-result", + toolCallId: "call-4", + toolName: "Read", + output: { type: "content", value: [{ type: "text", text: text("assistant tool content") }] }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-5", + toolName: "Read", + output: { type: "text", value: text("tool text") }, + }, + { + type: "tool-result", + toolCallId: "call-6", + toolName: "Read", + output: { type: "error-text", value: text("tool error") }, + }, + { + type: "tool-result", + toolCallId: "call-7", + toolName: "Read", + output: { type: "content", value: [{ type: "text", text: text("tool content") }] }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result[0].content).toBe(expected("system")) + expect(result[1].content).toBe(expected("user string")) + expect(result[2].content[0].text).toBe(expected("user text")) + expect(result[3].content).toBe(expected("assistant string")) + expect(result[4].content[0].text).toBe(expected("assistant text")) + expect(result[4].content[1].text).toBe(expected("assistant reasoning")) + expect(result[4].content[3].output.value).toBe(expected("assistant tool text")) + expect(result[4].content[4].output.value).toBe(expected("assistant tool error")) + expect(result[4].content[5].output.value[0].text).toBe(expected("assistant tool content")) + expect(result[5].content[0].output.value).toBe(expected("tool text")) + expect(result[5].content[1].output.value).toBe(expected("tool error")) + expect(result[5].content[2].output.value[0].text).toBe(expected("tool content")) + expect(result[2].content[1]).toEqual({ type: "image", image: "data:image/png;base64,abcd" }) + }) +}) + +describe("ProviderTransform.message - empty image handling", () => { + const mockModel = { + id: "anthropic/claude-3-5-sonnet", + providerID: "anthropic", + api: { + id: "claude-3-5-sonnet-20241022", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + name: "Claude 3.5 Sonnet", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.003, + output: 0.015, + cache: { read: 0.0003, write: 0.00375 }, + }, + limit: { + context: 200000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + } as any + + test("should replace empty base64 image with error text", () => { + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image", image: "data:image/png;base64," }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mockModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect(result[0].content[0]).toEqual({ type: "text", text: "What is in this image?" }) + expect(result[0].content[1]).toEqual({ + type: "text", + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", + }) + }) + + test("should keep valid base64 images unchanged", () => { + const validBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image", image: `data:image/png;base64,${validBase64}` }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mockModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect(result[0].content[0]).toEqual({ type: "text", text: "What is in this image?" }) + expect(result[0].content[1]).toEqual({ type: "image", image: `data:image/png;base64,${validBase64}` }) + }) + + test("should handle mixed valid and empty images", () => { + const validBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "Compare these images" }, + { type: "image", image: `data:image/png;base64,${validBase64}` }, + { type: "image", image: "data:image/jpeg;base64," }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mockModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(3) + expect(result[0].content[0]).toEqual({ type: "text", text: "Compare these images" }) + expect(result[0].content[1]).toEqual({ type: "image", image: `data:image/png;base64,${validBase64}` }) + expect(result[0].content[2]).toEqual({ + type: "text", + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", + }) + }) +}) + +describe("ProviderTransform.message - anthropic empty content filtering", () => { + const anthropicModel = { + id: "anthropic/claude-3-5-sonnet", + providerID: "anthropic", + api: { + id: "claude-3-5-sonnet-20241022", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + name: "Claude 3.5 Sonnet", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.003, + output: 0.015, + cache: { read: 0.0003, write: 0.00375 }, + }, + limit: { + context: 200000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + } as any + + test("filters out messages with empty string content", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "" }, + { role: "user", content: "World" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toBe("World") + }) + + test("filters out empty text parts from array content", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "text", text: "Hello" }, + { type: "text", text: "" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" }) + }) + + test("filters out empty reasoning parts from array content", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "" }, + { type: "text", text: "Answer" }, + { type: "reasoning", text: "" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" }) + }) + + test("removes entire message when all parts are empty", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "reasoning", text: "" }, + ], + }, + { role: "user", content: "World" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toBe("World") + }) + + test("keeps non-text/reasoning parts even if text parts are empty", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "tool-call", toolCallId: "123", toolName: "bash", input: { command: "ls" } }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(1) + expect(result[0].content[0]).toEqual({ + type: "tool-call", + toolCallId: "123", + toolName: "bash", + input: { command: "ls" }, + }) + }) + + test("keeps messages with valid text alongside empty parts", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Thinking..." }, + { type: "text", text: "" }, + { type: "text", text: "Result" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." }) + expect(result[0].content[1]).toEqual({ type: "text", text: "Result" }) + }) + + test("filters empty content for bedrock provider", () => { + const bedrockModel = { + ...anthropicModel, + id: "amazon-bedrock/anthropic.claude-opus-4-6", + providerID: "amazon-bedrock", + api: { + id: "anthropic.claude-opus-4-6", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + } + + const msgs = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "" }, + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "text", text: "Answer" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toHaveLength(1) + expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" }) + }) + + describe("Bedrock reasoning replay", () => { + const model = { + ...anthropicModel, + id: "amazon-bedrock/anthropic.claude-opus-4-6", + providerID: "amazon-bedrock", + api: { + id: "anthropic.claude-opus-4-6", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + } + + for (const cached of [false, true]) { + test(`omits unsigned reasoning before SDK conversion (caching: ${cached})`, async () => { + const selected = cached + ? model + : { ...model, id: "amazon-bedrock/openai.gpt-oss-120b", api: { ...model.api, id: "openai.gpt-oss-120b" } } + const messages = ProviderTransform.message( + [ + { role: "user", content: "Think" }, + { role: "assistant", content: [{ type: "text", text: "Earlier answer" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "Partial thought" }, + { type: "text", text: "" }, + ], + }, + { role: "user", content: "Continue" }, + ], + selected, + {}, + ) + expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "user"]) + expect(messages[1].providerOptions?.bedrock?.cachePoint).toEqual(cached ? { type: "default" } : undefined) + const provider = createAmazonBedrock({ + apiKey: "test-key", + region: "us-east-1", + fetch: Object.assign( + async (...args: Parameters) => { + const body = JSON.parse(String(args[1]?.body)) + expect(body.messages).toEqual([ + { role: "user", content: [{ text: "Think" }] }, + { + role: "assistant", + content: [{ text: "Earlier answer" }, ...(cached ? [{ cachePoint: { type: "default" } }] : [])], + }, + { + role: "user", + content: [{ text: "Continue" }, ...(cached ? [{ cachePoint: { type: "default" } }] : [])], + }, + ]) + return Response.json({ + output: { message: { role: "assistant", content: [{ text: "Recovered" }] } }, + stopReason: "end_turn", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }) + }, + { preconnect: () => undefined }, + ), + }) + const result = await generateText({ model: provider(selected.api.id), messages, maxRetries: 0 }) + expect(result.text).toBe("Recovered") + }) + } + + for (const namespace of ["bedrock", "amazon-bedrock", "custom-bedrock"]) { + for (const field of ["signature", "redactedContent", "redactedData"]) { + test(`preserves ${namespace}.${field} on empty reasoning`, () => { + const result = ProviderTransform.message( + [ + { + role: "assistant", + content: [{ type: "reasoning", text: "", providerOptions: { [namespace]: { [field]: "opaque" } } }], + }, + ], + namespace === "custom-bedrock" ? { ...model, providerID: namespace } : model, + {}, + ) + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { type: "reasoning", text: "", providerOptions: { bedrock: { [field]: "opaque" } } }, + ]) + }) + } + } + + test("uses stored provider metadata when it will overwrite the SDK namespace", () => { + const result = ProviderTransform.message( + [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Partial thought", + providerOptions: { "amazon-bedrock": {}, bedrock: { signature: "overwritten" } }, + }, + ], + }, + ], + model, + {}, + ) + expect(result).toEqual([]) + }) + + test("keeps text and tool calls next to unsigned reasoning", () => { + const content: ModelMessage["content"] = [ + { type: "text", text: "Answer" }, + { type: "tool-call", toolCallId: "call_1", toolName: "lookup", input: {} }, + ] + const result = ProviderTransform.message( + [{ role: "assistant", content: [{ type: "reasoning", text: "Partial thought" }, ...content] }], + model, + {}, + ) + expect(result).toHaveLength(1) + expect(result[0].content).toEqual(content) + }) + + test("does not remove unsigned reasoning for other providers", () => { + const content: ModelMessage["content"] = [{ type: "reasoning", text: "Partial thought" }] + const result = ProviderTransform.message([{ role: "assistant", content }], anthropicModel, {}) + expect(result[0].content).toEqual(content) + }) + }) + + test("does not filter for non-anthropic providers", () => { + const openaiModel = { + ...anthropicModel, + providerID: "openai", + api: { + id: "gpt-4", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + } + + const msgs = [ + { role: "assistant", content: "" }, + { + role: "assistant", + content: [{ type: "text", text: "" }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, openaiModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("") + expect(result[1].content).toHaveLength(1) + }) + + test("leaves valid anthropic assistant tool ordering unchanged", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "text", text: "I checked your home directory and looked for PDF files." }, + { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, + { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result).toHaveLength(1) + expect(result[0].content).toMatchObject([ + { type: "text", text: "I checked your home directory and looked for PDF files." }, + { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, + { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, + ]) + }) +}) + +describe("ProviderTransform.message - strip openai metadata when store=false", () => { + const openaiModel = { + id: "openai/gpt-5", + providerID: "openai", + api: { + id: "gpt-5", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + name: "GPT-5", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.03, output: 0.06, cache: { read: 0.001, write: 0.002 } }, + limit: { context: 128000, output: 4096 }, + status: "active", + options: {}, + headers: {}, + } as any + + test("strips OpenAI itemId and preserves reasoningEncryptedContent when store=false", () => { + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { + openai: { + itemId: "rs_123", + reasoningEncryptedContent: "encrypted", + }, + }, + }, + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_456", + }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] + + expect(result).toHaveLength(1) + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBe("encrypted") + expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + }) + + test("uses the SDK package namespace rather than provider ID", () => { + const zenModel = { + ...openaiModel, + providerID: "zen", + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { + openai: { + itemId: "rs_123", + reasoningEncryptedContent: "encrypted", + }, + }, + }, + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_456", + }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, zenModel, { store: false }) as any[] + + expect(result).toHaveLength(1) + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBe("encrypted") + expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + }) + + test("preserves other OpenAI options", () => { + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_123", + otherOption: "value", + }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.otherOption).toBe("value") + }) + + test("strips Azure itemId from the Azure namespace", () => { + const azureModel = { + ...openaiModel, + providerID: "azure", + api: { + id: "gpt-5", + url: "https://example.openai.azure.com", + npm: "@ai-sdk/azure", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + azure: { itemId: "msg_123", otherOption: "value" }, + openai: { itemId: "msg_openai" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, azureModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.azure?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.azure?.otherOption).toBe("value") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_openai") + }) + + test("strips Bedrock Mantle itemId from the OpenAI namespace", () => { + const mantleModel = { + ...openaiModel, + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5.5", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + } + const msgs = [ + { + role: "assistant", + providerOptions: { openai: { itemId: "msg_root", otherOption: "root-value" } }, + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { + openai: { itemId: "rs_123", reasoningEncryptedContent: "encrypted" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mantleModel, { store: false }) as any[] + + expect(result[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].providerOptions?.openai?.otherOption).toBe("root-value") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBe("encrypted") + }) + + test("strips GitHub Copilot itemId from the copilot namespace, preserving other copilot options", () => { + const copilotModel = { + ...openaiModel, + id: "github-copilot/gpt-5.5", + providerID: "github-copilot", + api: { + id: "gpt-5.5", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { + copilot: { itemId: "rs_123", reasoningEncryptedContent: "encrypted" }, + }, + }, + { + // The stale itemId on tool-call parts is what Copilot echoes back as the + // `function_call` item `id`, which is what the upstream connection rejects. + type: "tool-call", + toolCallId: "call_1", + toolName: "bash", + input: { command: "ls" }, + providerOptions: { + copilot: { itemId: "fc_456", reasoningEffort: "medium" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, copilotModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.copilot?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.copilot?.reasoningEncryptedContent).toBe("encrypted") + expect(result[0].content[1].providerOptions?.copilot?.itemId).toBeUndefined() + expect(result[0].content[1].providerOptions?.copilot?.reasoningEffort).toBe("medium") + }) + + test("leaves a stray openai namespace on a Copilot model untouched, since Copilot's Responses model only reads the copilot namespace", () => { + const copilotModel = { + ...openaiModel, + id: "github-copilot/gpt-5.5", + providerID: "github-copilot", + api: { + id: "gpt-5.5", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { itemId: "msg_456" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, copilotModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_456") + }) + + test("preserves metadata for openai package when store is true", () => { + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_123", + }, + }, + }, + ], + }, + ] as any[] + + // openai package preserves itemId regardless of store value + const result = ProviderTransform.message(msgs, openaiModel, { store: true }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + }) + + test("preserves metadata for non-openai packages when store is false", () => { + const anthropicModel = { + ...openaiModel, + providerID: "anthropic", + api: { + id: "claude-3", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_123", + }, + }, + }, + ], + }, + ] as any[] + + // store=false preserves metadata for non-openai packages + const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + }) + + test("preserves metadata using providerID key when store is false", () => { + const opencodeModel = { + ...openaiModel, + providerID: "opencode", + api: { + id: "opencode-test", + url: "https://api.opencode.ai", + npm: "@ai-sdk/openai-compatible", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + opencode: { + itemId: "msg_123", + otherOption: "value", + }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_123") + expect(result[0].content[0].providerOptions?.opencode?.otherOption).toBe("value") + }) + + test("preserves itemId across all providerOptions keys", () => { + const opencodeModel = { + ...openaiModel, + providerID: "opencode", + api: { + id: "opencode-test", + url: "https://api.opencode.ai", + npm: "@ai-sdk/openai-compatible", + }, + } + const msgs = [ + { + role: "assistant", + providerOptions: { + openai: { itemId: "msg_root" }, + opencode: { itemId: "msg_opencode" }, + extra: { itemId: "msg_extra" }, + }, + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { itemId: "msg_openai_part" }, + opencode: { itemId: "msg_opencode_part" }, + extra: { itemId: "msg_extra_part" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] + + expect(result[0].providerOptions?.openai?.itemId).toBe("msg_root") + expect(result[0].providerOptions?.opencode?.itemId).toBe("msg_opencode") + expect(result[0].providerOptions?.extra?.itemId).toBe("msg_extra") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_openai_part") + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_opencode_part") + expect(result[0].content[0].providerOptions?.extra?.itemId).toBe("msg_extra_part") + }) + + test("does not strip metadata for non-openai packages when store is not false", () => { + const anthropicModel = { + ...openaiModel, + providerID: "anthropic", + api: { + id: "claude-3", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_123", + }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + }) +}) + +describe("ProviderTransform.message - providerOptions key remapping", () => { + const createModel = (providerID: string, npm: string) => + ({ + id: `${providerID}/test-model`, + providerID, + api: { + id: "test-model", + url: "https://api.test.com", + npm, + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + }) as any + + test("azure keeps 'azure' key and does not remap to 'openai'", () => { + const model = createModel("azure", "@ai-sdk/azure") + const msgs = [ + { + role: "user", + content: "Hello", + providerOptions: { + azure: { someOption: "value" }, + }, + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].providerOptions?.azure).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.openai).toBeUndefined() + }) + + test("azure cognitive services remaps providerID to 'azure' key", () => { + const model = createModel("azure-cognitive-services", "@ai-sdk/azure") + const msgs = [ + { + role: "user", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + "azure-cognitive-services": { part: true }, + }, + }, + ], + providerOptions: { + "azure-cognitive-services": { someOption: "value" }, + }, + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + const part = result[0].content[0] as any + + expect(result[0].providerOptions?.azure).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.["azure-cognitive-services"]).toBeUndefined() + expect(part.providerOptions?.azure).toEqual({ part: true }) + expect(part.providerOptions?.["azure-cognitive-services"]).toBeUndefined() + }) + + test("copilot remaps providerID to 'copilot' key", () => { + const model = createModel("github-copilot", "@ai-sdk/github-copilot") + const msgs = [ + { + role: "user", + content: "Hello", + providerOptions: { + copilot: { someOption: "value" }, + }, + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].providerOptions?.copilot).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.["github-copilot"]).toBeUndefined() + }) + + test("bedrock remaps providerID to 'bedrock' key", () => { + const model = createModel("my-bedrock", "@ai-sdk/amazon-bedrock") + const msgs = [ + { + role: "user", + content: "Hello", + providerOptions: { + "my-bedrock": { someOption: "value" }, + }, + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].providerOptions?.bedrock).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.["my-bedrock"]).toBeUndefined() + }) +}) + +describe("ProviderTransform.message - claude w/bedrock custom inference profile", () => { + test("adds cachePoint", () => { + const model = { + id: "amazon-bedrock/custom-claude-sonnet-4.5", + providerID: "amazon-bedrock", + api: { + id: "arn:aws:bedrock:xxx:yyy:application-inference-profile/zzz", + url: "https://api.test.com", + npm: "@ai-sdk/amazon-bedrock", + }, + name: "Custom inference profile", + capabilities: {}, + options: {}, + headers: {}, + } as any + + const msgs = [ + { + role: "user", + content: "Hello", + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].providerOptions?.bedrock).toEqual( + expect.objectContaining({ + cachePoint: { + type: "default", + }, + }), + ) + }) +}) + +describe("ProviderTransform.message - bedrock caching with non-bedrock providerID", () => { + test("applies cache options at message level when npm package is amazon-bedrock", () => { + const model = { + id: "aws/us.anthropic.claude-opus-4-6-v1", + providerID: "aws", + api: { + id: "us.anthropic.claude-opus-4-6-v1", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + name: "Claude Opus 4.6", + capabilities: {}, + options: {}, + headers: {}, + } as any + + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: [{ type: "text", text: "Hello" }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + // Cache should be at the message level and not the content-part level + expect(result[0].providerOptions?.bedrock).toEqual({ + cachePoint: { type: "default" }, + }) + expect(result[0].content).toBe("You are a helpful assistant") + }) +}) + +describe("ProviderTransform.message - cache control on gateway", () => { + const createModel = (overrides: Partial = {}) => + ({ + id: "anthropic/claude-sonnet-4", + providerID: "vercel", + api: { + id: "anthropic/claude-sonnet-4", + url: "https://ai-gateway.vercel.sh/v3/ai", + npm: "@ai-sdk/gateway", + }, + name: "Claude Sonnet 4", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, + limit: { context: 200_000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + ...overrides, + }) as any + + test("gateway does not set cache control for anthropic models", () => { + const model = createModel() + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: "Hello", + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result[0].content).toBe("You are a helpful assistant") + expect(result[0].providerOptions).toBeUndefined() + }) + + test("non-gateway anthropic keeps existing cache control behavior", () => { + const model = createModel({ + providerID: "anthropic", + api: { + id: "claude-sonnet-4", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: "Hello", + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result[0].providerOptions).toEqual({ + anthropic: { + cacheControl: { + type: "ephemeral", + }, + }, + openrouter: { + cacheControl: { + type: "ephemeral", + }, + }, + bedrock: { + cachePoint: { + type: "default", + }, + }, + openaiCompatible: { + cache_control: { + type: "ephemeral", + }, + }, + copilot: { + copilot_cache_control: { + type: "ephemeral", + }, + }, + alibaba: { + cacheControl: { + type: "ephemeral", + }, + }, + }) + }) + + test("does not add explicit breakpoints when Anthropic automatic caching is enabled", () => { + const model = createModel({ + providerID: "anthropic", + api: { id: "claude-sonnet-4", url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" }, + }) + const msgs = [ + { role: "system", content: "You are a helpful assistant" }, + { role: "user", content: "Hello" }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, { cacheControl: { type: "ephemeral" } }) as any[] + expect(result.every((message) => message.providerOptions === undefined)).toBe(true) + }) + + test("google-vertex-anthropic applies cache control", () => { + const model = createModel({ + providerID: "google-vertex-anthropic", + api: { + id: "google-vertex-anthropic", + url: "https://us-central1-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex/anthropic", + }, + id: "claude-sonnet-4@20250514", + }) + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: "Hello", + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result[0].providerOptions).toEqual({ + anthropic: { + cacheControl: { + type: "ephemeral", + }, + }, + openrouter: { + cacheControl: { + type: "ephemeral", + }, + }, + bedrock: { + cachePoint: { + type: "default", + }, + }, + openaiCompatible: { + cache_control: { + type: "ephemeral", + }, + }, + copilot: { + copilot_cache_control: { + type: "ephemeral", + }, + }, + alibaba: { + cacheControl: { + type: "ephemeral", + }, + }, + }) + }) +}) + +describe("ProviderTransform.temperature - Cohere North", () => { + test("defaults north-mini-code models to 1.0", () => { + expect( + ProviderTransform.temperature({ + id: "cohere/North-Mini-Code-1-0-latest", + api: { id: "North-Mini-Code-1-0-latest" }, + } as any), + ).toBe(1.0) + }) +}) + +describe("ProviderTransform sampling defaults - Qwen", () => { + test.each(["Qwen3.8-27B", "qwen3-coder-30b-a3b-instruct"])('leaves sampling unset for "%s"', (id) => { + const model = { + id: `custom/${id}`, + api: { id }, + } as any + + expect(ProviderTransform.temperature(model)).toBeUndefined() + expect(ProviderTransform.topP(model)).toBeUndefined() + expect(ProviderTransform.topK(model)).toBeUndefined() + }) +}) + +describe("ProviderTransform sampling defaults - Gemini", () => { + const model = (id: string) => + ({ + id: `google/${id}`, + api: { id }, + }) as any + + const alias = (id: string, apiID: string) => + ({ + id, + api: { id: apiID }, + }) as any + + test.each([ + "gemini-3.5-flash-lite", + "gemini-3-5-flash-lite", + "gemini-3.6-flash", + "gemini-3-6-flash", + "gemini-4-pro", + "gemini-future", + ])("omits deprecated sampling controls for %s", (id) => { + expect(ProviderTransform.temperature(model(id))).toBeUndefined() + expect(ProviderTransform.topP(model(id))).toBeUndefined() + expect(ProviderTransform.topK(model(id))).toBeUndefined() + }) + + test.each([ + "gemini-2.5-flash", + "gemini-2.5-pro", + "gemini-2.5-flash-lite", + "gemini-2-5-flash-lite", + "gemini-3-flash-preview", + "gemini-3-pro-image", + "gemini-3.1-flash-lite", + "gemini-3.1-pro-preview", + "gemini-3-1-pro-preview", + "gemini-3.5-flash", + "gemini-3-5-flash", + ])("preserves sampling defaults for %s", (id) => { + expect(ProviderTransform.temperature(model(id))).toBe(1) + expect(ProviderTransform.topP(model(id))).toBe(0.95) + expect(ProviderTransform.topK(model(id))).toBe(64) + }) + + test("uses the API model ID for configured aliases", () => { + const deprecated = alias("google/gemini-3.5-flash", "google/gemini-3.6-flash") + expect(ProviderTransform.temperature(deprecated)).toBeUndefined() + expect(ProviderTransform.topP(deprecated)).toBeUndefined() + expect(ProviderTransform.topK(deprecated)).toBeUndefined() + + const supported = alias("my-gemini", "google/gemini-2.5-flash") + expect(ProviderTransform.temperature(supported)).toBe(1) + expect(ProviderTransform.topP(supported)).toBe(0.95) + expect(ProviderTransform.topK(supported)).toBe(64) + }) +}) + +describe("ProviderTransform sampling defaults - DeepSeek", () => { + const model = (providerID: string, id: string) => + ({ + id: `${providerID}/${id}`, + providerID, + api: { id }, + }) as any + + test.each([ + ["deepseek", "deepseek-v4-flash"], + ["opencode", "deepseek-v4-flash"], + ["opencode-go", "deepseek-v4-flash"], + ["openrouter", "deepseek/deepseek-v4-flash-0731"], + ["ollama-cloud", "deepseek-v4-flash:0731"], + ])("defaults top_p for %s/%s", (providerID, id) => { + expect(ProviderTransform.temperature(model(providerID, id))).toBeUndefined() + expect(ProviderTransform.topP(model(providerID, id))).toBe(0.95) + expect(ProviderTransform.topK(model(providerID, id))).toBeUndefined() + }) + + test.each([ + ["openrouter", "deepseek/deepseek-v4-flash"], + ["vercel", "deepseek/deepseek-v4-flash"], + ["custom", "deepseek-ai/DeepSeek-V4-Flash"], + ])("preserves legacy defaults for %s/%s", (providerID, id) => { + expect(ProviderTransform.temperature(model(providerID, id))).toBeUndefined() + expect(ProviderTransform.topP(model(providerID, id))).toBeUndefined() + expect(ProviderTransform.topK(model(providerID, id))).toBeUndefined() + }) +}) + +describe("ProviderTransform.reasoningVariants", () => { + const model = (reasoning_options: ModelsDev.Model["reasoning_options"]) => ({ reasoning_options }) as ModelsDev.Model + const target = (npm: string, id = "test-model", family = "") => + ({ + id, + providerID: "test", + family, + api: { id, npm, url: "" }, + capabilities: { reasoning: true }, + limit: { output: 64_000 }, + }) as any + + test("respects explicitly empty reasoning options", () => { + expect(ProviderTransform.reasoningVariants(model([]), target("@ai-sdk/openai"))).toEqual({}) + }) + + test.each([ + ["@openrouter/ai-sdk-provider", { reasoning: { effort: "high" } }], + ["@ai-sdk/anthropic", { thinking: { type: "adaptive" }, effort: "high" }, "claude-opus-4-6"], + [ + "@ai-sdk/google-vertex/anthropic", + { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + "claude-opus-4-7", + ], + ["@ai-sdk/anthropic", { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, "claude-opus-5"], + ["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }], + ["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }], + [ + "@ai-sdk/azure", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + [ + "@ai-sdk/openai", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + [ + "@ai-sdk/amazon-bedrock/mantle", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + [ + "@ai-sdk/github-copilot", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + ["@ai-sdk/openai-compatible", { reasoningEffort: "high" }], + ["@ai-sdk/xai", { reasoningEffort: "high" }], + ["@ai-sdk/mistral", { reasoningEffort: "high" }], + ["@ai-sdk/groq", { reasoningEffort: "high" }], + ["@ai-sdk/cerebras", { reasoningEffort: "high" }], + ["@ai-sdk/deepinfra", { reasoningEffort: "high" }], + ["@ai-sdk/togetherai", { reasoningEffort: "high" }], + ["venice-ai-sdk-provider", { reasoningEffort: "high" }], + ["ai-gateway-provider", { reasoningEffort: "high" }], + ["merge-gateway-ai-sdk-provider", { reasoningEffort: "high" }], + ["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }], + ])("converts effort for %s", (npm, expected, ...args) => { + const id = args[0] as string | undefined + expect(ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm, id))).toEqual( + { high: expected }, + ) + }) + + test.each(["luna", "sol", "terra"])("serializes Bedrock GPT-5.6 %s none effort", async (name) => { + const item = target("@ai-sdk/amazon-bedrock", `global.openai.gpt-5.6-${name}`) + const variants = ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["none", "low", "medium", "high", "xhigh", "max"] }]), + item, + ) + for (const effort of ["low", "medium", "high", "xhigh", "max"]) { + expect(variants?.[effort]).toEqual({ reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }) + } + expect(variants?.none).toEqual({ + reasoningConfig: { type: "enabled", maxReasoningEffort: "none" }, + } satisfies AmazonBedrockLanguageModelOptions) + const sent: unknown[] = [] + const provider = createAmazonBedrock({ + apiKey: "test-key", + region: "us-east-1", + fetch: Object.assign( + async (...args: Parameters) => { + sent.push(JSON.parse(String(args[1]?.body))) + return Response.json({ + output: { message: { role: "assistant", content: [{ text: "ok" }] } }, + stopReason: "end_turn", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }) + }, + { preconnect: () => undefined }, + ), + }) + await generateText({ + model: provider(item.api.id), + prompt: "hi", + providerOptions: ProviderTransform.providerOptions(item, variants?.none ?? {}), + }) + expect(sent).toEqual([expect.objectContaining({ additionalModelRequestFields: { reasoning: { effort: "none" } } })]) + }) + + test("combines effort with extended thinking for Claude Opus 4.5", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/anthropic", "claude-opus-4-5"), + ), + ).toEqual({ + high: { + thinking: { type: "enabled", budgetTokens: 16_000 }, + effort: "high", + }, + }) + }) + + test("uses explicit effort metadata for Anthropic-compatible models", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/anthropic", "claude-sonnet-4"), + ), + ).toEqual({ high: { effort: "high" } }) + + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["max"] }]), + target("@ai-sdk/anthropic", "k3"), + ), + ).toEqual({ max: { effort: "max" } }) + }) + + test("maps Kimi effort metadata to adaptive thinking", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["low", "high", "max"] }]), + target("@ai-sdk/anthropic", "kimi-k3"), + ), + ).toEqual({ + low: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, + high: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + max: { thinking: { type: "adaptive", display: "summarized" }, effort: "max" }, + }) + }) + + test("maps GitLab model efforts to provider-specific options", () => { + const options = model([{ type: "effort", values: ["max"] }]) + const openai = target("gitlab-ai-provider", "duo-chat-gpt-5-6-sol", "gpt-sol") + const anthropic = target("gitlab-ai-provider", "duo-chat-opus-4-8", "claude-opus") + + expect(ProviderTransform.reasoningVariants(options, openai)).toEqual({ + max: { reasoningEffort: "max" }, + }) + expect(ProviderTransform.reasoningVariants(options, anthropic)).toEqual({ + max: { thinking: { type: "adaptive", effort: "max" } }, + }) + }) + + test("uses adaptive reasoning config for Anthropic models on Bedrock", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/amazon-bedrock", "anthropic.claude-opus-4-7-v1:0"), + ), + ).toEqual({ + high: { + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }, + }) + }) + + test("uses adaptive reasoning config for Claude Opus 5 on Bedrock", () => { + const result = ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }]), + target("@ai-sdk/amazon-bedrock", "us.anthropic.claude-opus-5"), + ) + expect(Object.keys(result ?? {})).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result?.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + + test("combines effort with extended thinking for Claude Opus 4.5 on Bedrock", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/amazon-bedrock", "us.anthropic.claude-opus-4-5-20251101-v1:0"), + ), + ).toEqual({ + high: { + reasoningConfig: { + type: "enabled", + budgetTokens: 16_000, + maxReasoningEffort: "high", + }, + }, + }) + }) + + test("does not replace unsupported Anthropic Bedrock effort options with token budgets", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/amazon-bedrock", "anthropic.claude-sonnet-4-v1:0"), + ), + ).toEqual({}) + }) + + test.each([ + ["@openrouter/ai-sdk-provider", { reasoning: { max_tokens: 16_000 } }], + ["@ai-sdk/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }], + ["@ai-sdk/google-vertex/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }], + ["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }], + ["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }], + ["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", budgetTokens: 16_000 } }], + ["@ai-sdk/cohere", { thinking: { type: "enabled", tokenBudget: 16_000 } }], + ["@ai-sdk/alibaba", { enableThinking: true, thinkingBudget: 16_000 }], + ])("converts token budgets for %s", (npm, high) => { + const variants = ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024 }]), target(npm)) + expect(variants?.high).toEqual(high) + expect(Object.keys(variants ?? {})).toEqual(["high", "max"]) + }) + + test("maps null effort to none", () => { + expect( + ProviderTransform.reasoningVariants(model([{ type: "effort", values: [null] }]), target("@ai-sdk/openai")), + ).toEqual({ + none: { + reasoningEffort: "none", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }) + }) + + test.each([ + ["@ai-sdk/alibaba", { none: { enableThinking: false }, high: { enableThinking: true } }], + [ + "@ai-sdk/cohere", + { + none: { thinking: { type: "disabled" } }, + high: { thinking: { type: "enabled" } }, + }, + ], + ])("converts toggle options for %s", (npm, expected) => { + expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target(npm))).toEqual(expected) + }) + + test("combines Cohere toggle and budget options", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "toggle" }, { type: "budget_tokens", min: 1 }]), + target("@ai-sdk/cohere"), + ), + ).toEqual({ + none: { thinking: { type: "disabled" } }, + high: { thinking: { type: "enabled", tokenBudget: 16_000 } }, + max: { thinking: { type: "enabled", tokenBudget: 31_999 } }, + }) + }) + + test("generates bounded high and max token budgets", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), + target("@ai-sdk/anthropic"), + ), + ).toEqual({ + high: { thinking: { type: "enabled", budgetTokens: 16_000 } }, + max: { thinking: { type: "enabled", budgetTokens: 31_999 } }, + }) + }) + + test("caps token budgets below the model output limit", () => { + const anthropic = target("@ai-sdk/anthropic") + anthropic.limit.output = 5_000 + expect( + ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), anthropic), + ).toEqual({ + high: { thinking: { type: "enabled", budgetTokens: 2_500 } }, + max: { thinking: { type: "enabled", budgetTokens: 4_999 } }, + }) + }) + + test("derives high and max budgets when models.dev omits max", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "budget_tokens", min: 1_024 }]), + target("@ai-sdk/anthropic", "claude-haiku-4-5"), + ), + ).toEqual({ + high: { thinking: { type: "enabled", budgetTokens: 16_000 } }, + max: { thinking: { type: "enabled", budgetTokens: 31_999 } }, + }) + }) + + test("preserves explicit inclusive budget maxima", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "budget_tokens", min: 1_024, max: 24_576 }]), + target("@ai-sdk/google", "gemini-2.5-pro"), + ), + ).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 12_288 } }, + max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 24_576 } }, + }) + }) + + test("prefers effort options over token budgets", () => { + expect( + ProviderTransform.reasoningVariants( + model([ + { type: "budget_tokens", min: 1_024, max: 64_000 }, + { type: "effort", values: ["low"] }, + ]), + target("@ai-sdk/openai"), + ), + ).toEqual({ + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }) + }) + + test("does not replace unsupported effort options with heuristic variants", () => { + expect( + ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target("@ai-sdk/perplexity")), + ).toEqual({}) + }) + + test("leaves unsupported toggle options for heuristic fallback", () => { + expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target("@ai-sdk/openai"))).toBeUndefined() + }) + + test("uses model-family options for gateway and GitHub Copilot", () => { + const effort = model([{ type: "effort", values: ["high"] }]) + expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "anthropic/claude-sonnet-4"))).toEqual( + { + high: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + ) + expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "google/gemini-3-pro"))).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }) + expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/github-copilot", "gemini-3-pro"))).toEqual({}) + }) + + test.each(["@ai-sdk/cohere", "@ai-sdk/perplexity", "@ai-sdk/vercel", "@ai-sdk/alibaba"])( + "does not invent effort controls for %s", + (npm) => { + expect(ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm))).toEqual( + {}, + ) + }, + ) +}) + +describe("ProviderTransform.variants", () => { + const createMockModel = (overrides: Partial = {}): any => ({ + id: "test/test-model", + providerID: "test", + api: { + id: "test-model", + url: "https://api.test.com", + npm: "@ai-sdk/openai", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 200_000, + output: 64_000, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2024-01-01", + ...overrides, + }) + + test("returns empty object when model has no reasoning capabilities", () => { + const model = createMockModel({ + capabilities: { reasoning: false }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("deepseek returns empty object", () => { + const model = createMockModel({ + id: "deepseek/deepseek-chat", + providerID: "deepseek", + api: { + id: "deepseek-chat", + url: "https://api.deepseek.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("minimax returns empty object", () => { + const model = createMockModel({ + id: "minimax/minimax-model", + providerID: "minimax", + api: { + id: "minimax-model", + url: "https://api.minimax.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("minimax m3 using anthropic returns thinking toggles", () => { + const model = createMockModel({ + id: "minimax/minimax-m3", + providerID: "minimax", + api: { + id: "MiniMax-M3", + url: "https://api.minimax.com/anthropic/v1", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({ + none: { thinking: { type: "disabled" } }, + thinking: { thinking: { type: "adaptive" } }, + }) + }) + + test("minimax m3 using openai-compatible returns thinking toggles", () => { + const model = createMockModel({ + id: "minimax/minimax-m3", + providerID: "minimax", + api: { + id: "minimax-m3", + url: "https://api.minimax.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + none: { thinking: { type: "disabled" } }, + thinking: { thinking: { type: "adaptive" } }, + }) + }) + + test.each(["nvidia", "lilac"])("%s minimax m3 returns chat template thinking toggles", (providerID) => { + const model = createMockModel({ + id: `${providerID}/minimaxai/minimax-m3`, + providerID, + api: { + id: "minimaxai/minimax-m3", + url: "https://api.example.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + none: { chat_template_kwargs: { thinking_mode: "disabled" } }, + thinking: { chat_template_kwargs: { thinking_mode: "enabled" } }, + }) + }) + + test("glm returns empty object", () => { + const model = createMockModel({ + id: "glm/glm-4", + providerID: "glm", + api: { + id: "glm-4", + url: "https://api.glm.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("glm-5.2 returns native effort variants for openai-compatible providers", () => { + const model = createMockModel({ + id: "zhipuai/glm-5.2", + providerID: "zhipuai", + api: { + id: "glm-5.2", + url: "https://open.bigmodel.cn/api/paas/v4", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) + }) + + test("recognizes GLM-5.2 provider model IDs", () => { + for (const id of ["accounts/fireworks/models/glm-5p2", "zai-org-glm-5-2", "umans-glm-5.2"]) { + const model = createMockModel({ + id: `test/${id}`, + api: { + id, + url: "https://api.test.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) + } + }) + + test("recognizes GLM-5.2 from the API ID when the configured model ID is an alias", () => { + const model = createMockModel({ + id: "custom/my-glm", + api: { + id: "accounts/fireworks/models/glm-5p2", + url: "https://api.fireworks.ai/inference/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) + }) + + test("glm-5.2 returns openrouter effort variants for openrouter", () => { + const model = createMockModel({ + id: "openrouter/z-ai/glm-5.2", + providerID: "openrouter", + api: { + id: "z-ai/glm-5.2", + url: "https://openrouter.ai/api/v1", + npm: "@openrouter/ai-sdk-provider", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoning: { effort: "high" } }, + xhigh: { reasoning: { effort: "xhigh" } }, + }) + }) + + test("glm-5.2 returns effort variants for anthropic-compatible providers", () => { + const model = createMockModel({ + id: "zai-coding-plan/glm-5.2", + providerID: "zai-coding-plan", + api: { + id: "glm-5.2", + url: "https://api.z.ai/api/anthropic", + npm: "@ai-sdk/anthropic", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { effort: "high" }, + max: { effort: "max" }, + }) + }) + + test("glm-5.2 falls back to provider defaults for other packages", () => { + const model = createMockModel({ + id: "test/glm-5.2", + api: { + id: "glm-5.2", + url: "https://api.test.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + low: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } }, + medium: { reasoningConfig: { type: "enabled", maxReasoningEffort: "medium" } }, + high: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }, + }) + }) + + test("mistral models with reasoning support return variants", () => { + const model = createMockModel({ + id: "mistral/mistral-small-latest", + providerID: "mistral", + api: { + id: "mistral-small-latest", + url: "https://api.mistral.com", + npm: "@ai-sdk/mistral", + }, + capabilities: { reasoning: true }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({ + high: { reasoningEffort: "high" }, + }) + }) + + test("mistral-medium-3.5 with reasoning returns variants", () => { + const model = createMockModel({ + id: "mistral/mistral-medium-3.5", + providerID: "mistral", + api: { + id: "mistral-medium-3.5", + url: "https://api.mistral.com", + npm: "@ai-sdk/mistral", + }, + capabilities: { reasoning: true }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({ + high: { reasoningEffort: "high" }, + }) + }) + + test("mistral without reasoning returns empty object", () => { + const model = createMockModel({ + id: "mistral/mistral-large", + providerID: "mistral", + api: { + id: "mistral-large-latest", + url: "https://api.mistral.com", + npm: "@ai-sdk/mistral", + }, + capabilities: { reasoning: false }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("mistral large with reasoning returns empty object (only small supports reasoning)", () => { + const model = createMockModel({ + id: "mistral/mistral-large", + providerID: "mistral", + api: { + id: "mistral-large-latest", + url: "https://api.mistral.com", + npm: "@ai-sdk/mistral", + }, + capabilities: { reasoning: true }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + describe("@openrouter/ai-sdk-provider", () => { + test("returns widely supported efforts for other reasoning models", () => { + const model = createMockModel({ + id: "openrouter/test-model", + providerID: "openrouter", + api: { + id: "test-model", + url: "https://openrouter.ai", + npm: "@openrouter/ai-sdk-provider", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.medium).toEqual({ reasoning: { effort: "medium" } }) + }) + + test("gpt models return OPENAI_EFFORTS with reasoning", () => { + const model = createMockModel({ + id: "openrouter/gpt-4", + providerID: "openrouter", + api: { + id: "gpt-4", + url: "https://openrouter.ai", + npm: "@openrouter/ai-sdk-provider", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) + expect(result.low).toEqual({ reasoning: { effort: "low" } }) + expect(result.high).toEqual({ reasoning: { effort: "high" } }) + }) + + for (const testCase of [ + { id: "openai/o3-mini", efforts: ["none", "minimal", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.4", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-pro", efforts: ["high"] }, + { id: "openai/gpt-5.5-pro", efforts: ["medium", "high", "xhigh"] }, + { id: "openai/gpt-5.2-codex", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.3-codex", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.3-codex-max", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-chat-latest", efforts: [] }, + { id: "openai/gpt-5.2-chat-latest", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported OpenAI reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: testCase.id, + providerID: "openrouter", + api: { + id: testCase.id, + url: "https://openrouter.ai", + npm: "@openrouter/ai-sdk-provider", + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } + + test("gemini-3 returns widely supported efforts with reasoning", () => { + const model = createMockModel({ + id: "openrouter/gemini-3-5-pro", + providerID: "openrouter", + api: { + id: "gemini-3-5-pro", + url: "https://openrouter.ai", + npm: "@openrouter/ai-sdk-provider", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + + test("grok-4 uses the provider's standard efforts", () => { + const model = createMockModel({ + id: "openrouter/grok-4", + providerID: "openrouter", + api: { + id: "grok-4", + url: "https://openrouter.ai", + npm: "@openrouter/ai-sdk-provider", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.medium).toEqual({ reasoning: { effort: "medium" } }) + }) + + test("grok-3-mini returns low and high with reasoning", () => { + const model = createMockModel({ + id: "openrouter/grok-3-mini", + providerID: "openrouter", + api: { + id: "grok-3-mini", + url: "https://openrouter.ai", + npm: "@openrouter/ai-sdk-provider", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "high"]) + expect(result.low).toEqual({ reasoning: { effort: "low" } }) + expect(result.high).toEqual({ reasoning: { effort: "high" } }) + }) + }) + + describe("@ai-sdk/gateway", () => { + test("configured anthropic aliases route by the API ID", () => { + const model = createMockModel({ + id: "my-claude", + providerID: "gateway", + api: { + id: "anthropic/claude-sonnet-4-6", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + }, + effort: "high", + }) + }) + + test("configured google aliases route by the API ID", () => { + const model = createMockModel({ + id: "my-gemini", + providerID: "gateway", + api: { + id: "google/gemini-2.5-pro", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }, + max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } }, + }) + }) + + test("anthropic sonnet 4.6 models return adaptive thinking options", () => { + const model = createMockModel({ + id: "anthropic/claude-sonnet-4-6", + providerID: "gateway", + api: { + id: "anthropic/claude-sonnet-4-6", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + expect(result.medium).toEqual({ + thinking: { + type: "adaptive", + }, + effort: "medium", + }) + }) + + test("anthropic sonnet 4.6 dot-format models return adaptive thinking options", () => { + const model = createMockModel({ + id: "anthropic/claude-sonnet-4-6", + providerID: "gateway", + api: { + id: "anthropic/claude-sonnet-4.6", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + expect(result.medium).toEqual({ + thinking: { + type: "adaptive", + }, + effort: "medium", + }) + }) + + test("anthropic opus 4.6 dot-format models return adaptive thinking options", () => { + const model = createMockModel({ + id: "anthropic/claude-opus-4-6", + providerID: "gateway", + api: { + id: "anthropic/claude-opus-4.6", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + }, + effort: "high", + }) + }) + + test("anthropic opus 4.7 models return adaptive thinking options with xhigh", () => { + const model = createMockModel({ + id: "anthropic/claude-opus-4-7", + providerID: "gateway", + api: { + id: "anthropic/claude-opus-4-7", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.xhigh).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "xhigh", + }) + expect(result.max).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "max", + }) + }) + + test("anthropic opus 4.7 dot-format models return adaptive thinking options with xhigh", () => { + const model = createMockModel({ + id: "anthropic/claude-opus-4-7", + providerID: "gateway", + api: { + id: "anthropic/claude-opus-4.7", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + }) + + test("anthropic opus 4.8 forces display summarized for adaptive reasoning", () => { + const model = createMockModel({ + id: "anthropic/claude-opus-4-8", + providerID: "gateway", + api: { + id: "anthropic/claude-opus-4-8", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) + + test("anthropic sonnet 5 returns adaptive thinking options with xhigh", () => { + const model = createMockModel({ + id: "anthropic/claude-sonnet-5", + providerID: "gateway", + api: { + id: "anthropic/claude-sonnet-5", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) + + test("anthropic opus 4.6 omits display so it keeps the summarized default", () => { + const model = createMockModel({ + id: "anthropic/claude-opus-4-6", + providerID: "gateway", + api: { + id: "anthropic/claude-opus-4-6", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + }, + effort: "high", + }) + }) + + test("anthropic models return anthropic thinking options", () => { + const model = createMockModel({ + id: "anthropic/claude-sonnet-4", + providerID: "gateway", + api: { + id: "anthropic/claude-sonnet-4", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["high", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "enabled", + budgetTokens: 16000, + }, + }) + expect(result.max).toEqual({ + thinking: { + type: "enabled", + budgetTokens: 31999, + }, + }) + }) + + test("returns OPENAI_EFFORTS with reasoningEffort", () => { + const model = createMockModel({ + id: "gateway/gateway-model", + providerID: "gateway", + api: { + id: "gateway-model", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) + expect(result.low).toEqual({ reasoningEffort: "low" }) + expect(result.high).toEqual({ reasoningEffort: "high" }) + }) + + for (const testCase of [ + { id: "openai/gpt-5-5", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-pro", efforts: ["high"] }, + { id: "openai/gpt-5-5-pro", efforts: ["medium", "high", "xhigh"] }, + { id: "openai/gpt-5-2-codex", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-3-codex", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-3-codex-max", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-chat-latest", efforts: [] }, + { id: "openai/gpt-5-2-chat-latest", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported OpenAI reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: testCase.id, + providerID: "gateway", + api: { + id: testCase.id, + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } + }) + + describe("@ai-sdk/github-copilot", () => { + test("standard models return low, medium, high", () => { + const model = createMockModel({ + id: "gpt-4.5", + providerID: "github-copilot", + api: { + id: "gpt-4.5", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.low).toEqual({ + reasoningEffort: "low", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + }) + + test("gpt-5.1-codex-max includes xhigh", () => { + const model = createMockModel({ + id: "gpt-5.1-codex-max", + providerID: "github-copilot", + api: { + id: "gpt-5.1-codex-max", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"]) + }) + + test("gpt-5.1-codex-mini does not include xhigh", () => { + const model = createMockModel({ + id: "gpt-5.1-codex-mini", + providerID: "github-copilot", + api: { + id: "gpt-5.1-codex-mini", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + + test("gpt-5.1-codex does not include xhigh", () => { + const model = createMockModel({ + id: "gpt-5.1-codex", + providerID: "github-copilot", + api: { + id: "gpt-5.1-codex", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + + test("gpt-5.2 includes xhigh", () => { + const model = createMockModel({ + id: "gpt-5.2", + providerID: "github-copilot", + api: { + id: "gpt-5.2", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"]) + expect(result.xhigh).toEqual({ + reasoningEffort: "xhigh", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + }) + + test("gpt-5.2-codex includes xhigh", () => { + const model = createMockModel({ + id: "gpt-5.2-codex", + providerID: "github-copilot", + api: { + id: "gpt-5.2-codex", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"]) + }) + + test("gpt-5.3-codex includes xhigh", () => { + const model = createMockModel({ + id: "gpt-5.3-codex", + providerID: "github-copilot", + api: { + id: "gpt-5.3-codex", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"]) + }) + + test("gpt-5.4 includes xhigh", () => { + const model = createMockModel({ + id: "gpt-5.4", + release_date: "2026-03-05", + providerID: "github-copilot", + api: { + id: "gpt-5.4", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"]) + }) + }) + + describe("@ai-sdk/cerebras", () => { + test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => { + const model = createMockModel({ + id: "cerebras/llama-4", + providerID: "cerebras", + api: { + id: "llama-4-sc", + url: "https://api.cerebras.ai", + npm: "@ai-sdk/cerebras", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.low).toEqual({ reasoningEffort: "low" }) + expect(result.high).toEqual({ reasoningEffort: "high" }) + }) + }) + + describe("@ai-sdk/togetherai", () => { + test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => { + const model = createMockModel({ + id: "togetherai/llama-4", + providerID: "togetherai", + api: { + id: "llama-4-sc", + url: "https://api.togetherai.com", + npm: "@ai-sdk/togetherai", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.low).toEqual({ reasoningEffort: "low" }) + expect(result.high).toEqual({ reasoningEffort: "high" }) + }) + }) + + describe("@ai-sdk/xai", () => { + test("grok-4.5 uses standard reasoning efforts", () => { + const model = createMockModel({ + id: "xai/grok-4.5", + providerID: "xai", + api: { + id: "grok-4.5", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.medium).toEqual({ reasoningEffort: "medium" }) + }) + + test("grok-3-mini returns low and high with reasoningEffort", () => { + const model = createMockModel({ + id: "xai/grok-3-mini", + providerID: "xai", + api: { + id: "grok-3-mini", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "high"]) + expect(result.low).toEqual({ reasoningEffort: "low" }) + expect(result.high).toEqual({ reasoningEffort: "high" }) + }) + }) + + describe("@ai-sdk/deepinfra", () => { + test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => { + const model = createMockModel({ + id: "deepinfra/llama-4", + providerID: "deepinfra", + api: { + id: "llama-4-sc", + url: "https://api.deepinfra.com", + npm: "@ai-sdk/deepinfra", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.low).toEqual({ reasoningEffort: "low" }) + expect(result.high).toEqual({ reasoningEffort: "high" }) + }) + }) + + describe("@ai-sdk/openai-compatible", () => { + test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => { + const model = createMockModel({ + id: "custom-provider/custom-model", + providerID: "custom-provider", + api: { + id: "custom-model", + url: "https://api.custom.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.low).toEqual({ reasoningEffort: "low" }) + expect(result.high).toEqual({ reasoningEffort: "high" }) + }) + + test("north-mini-code-1-0 returns only none and high", () => { + const model = createMockModel({ + id: "cohere/north-mini-code-1-0", + providerID: "cohere", + api: { + id: "North-Mini-Code-1-0-latest", + url: "https://api.cohere.com/compatibility/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({ + none: { reasoningEffort: "none" }, + high: { reasoningEffort: "high" }, + }) + }) + }) + + describe("@ai-sdk/azure", () => { + test("o1-mini returns empty object", () => { + const model = createMockModel({ + id: "o1-mini", + providerID: "azure", + api: { + id: "o1-mini", + url: "https://azure.com", + npm: "@ai-sdk/azure", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("standard azure models return custom efforts with reasoningSummary", () => { + const model = createMockModel({ + id: "o1", + providerID: "azure", + api: { + id: "o1", + url: "https://azure.com", + npm: "@ai-sdk/azure", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.low).toEqual({ + reasoningEffort: "low", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + }) + + test("gpt-5 adds minimal effort", () => { + const model = createMockModel({ + id: "gpt-5", + providerID: "azure", + api: { + id: "gpt-5", + url: "https://azure.com", + npm: "@ai-sdk/azure", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"]) + }) + + for (const testCase of [ + { id: "gpt-5-1", efforts: ["none", "low", "medium", "high"] }, + { id: "gpt-5-4", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5.4", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5-5", efforts: ["none", "low", "medium", "high", "xhigh"] }, + ]) { + test(`${testCase.id} returns supported Azure reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: testCase.id, + providerID: "azure", + api: { + id: testCase.id, + url: "https://azure.com", + npm: "@ai-sdk/azure", + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } + }) + + describe("@ai-sdk/openai", () => { + test("gpt-5-pro returns only high effort", () => { + const model = createMockModel({ + id: "gpt-5-pro", + providerID: "openai", + api: { + id: "gpt-5-pro", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["high"]) + }) + + test("standard openai models return custom efforts with reasoningSummary", () => { + const model = createMockModel({ + id: "gpt-5", + providerID: "openai", + api: { + id: "gpt-5", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + release_date: "2024-06-01", + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"]) + expect(result.low).toEqual({ + reasoningEffort: "low", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + }) + + test("models after 2025-11-13 include 'none' effort", () => { + const model = createMockModel({ + id: "gpt-5-nano", + providerID: "openai", + api: { + id: "gpt-5-nano", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + release_date: "2025-11-14", + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high"]) + }) + + test("models after 2025-12-04 include 'xhigh' effort", () => { + const model = createMockModel({ + id: "openai/gpt-5-reasoning", + providerID: "openai", + api: { + id: "gpt-5-reasoning", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + release_date: "2025-12-05", + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) + }) + + for (const testCase of [ + { id: "o1", releaseDate: "2024-12-17", efforts: ["low", "medium", "high"] }, + { id: "o1-pro", releaseDate: "2025-03-19", efforts: ["low", "medium", "high"] }, + { id: "o3", releaseDate: "2025-04-16", efforts: ["low", "medium", "high"] }, + { id: "o3-mini", releaseDate: "2025-01-31", efforts: ["low", "medium", "high"] }, + { id: "o3-pro", releaseDate: "2025-06-10", efforts: ["low", "medium", "high"] }, + { id: "o4-mini", releaseDate: "2025-04-16", efforts: ["low", "medium", "high"] }, + { id: "o3-deep-research", releaseDate: "2025-06-26", efforts: ["medium"] }, + { id: "o4-mini-deep-research", releaseDate: "2025-06-26", efforts: ["medium"] }, + { id: "gpt-5.1", releaseDate: "2025-11-13", efforts: ["none", "low", "medium", "high"] }, + { id: "gpt-5.4", releaseDate: "2026-03-05", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { + id: "gpt-5.5", + modelID: "gpt-5-5", + releaseDate: "2026-04-23", + efforts: ["none", "low", "medium", "high", "xhigh"], + }, + { id: "gpt-5.4-pro", releaseDate: "2026-03-05", efforts: ["medium", "high", "xhigh"] }, + { id: "gpt-5.5-pro", releaseDate: "2026-04-23", efforts: ["medium", "high", "xhigh"] }, + { id: "gpt-5-codex", releaseDate: "2025-09-23", efforts: ["low", "medium", "high"] }, + { id: "gpt-5.1-codex", releaseDate: "2025-11-13", efforts: ["low", "medium", "high"] }, + { id: "gpt-5.1-codex-max", releaseDate: "2025-11-13", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "gpt-5.2-codex", releaseDate: "2025-12-11", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "gpt-5.3-codex", releaseDate: "2026-01-22", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5.3-codex-max", releaseDate: "2026-01-22", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5-chat-latest", releaseDate: "2025-08-07", efforts: [] }, + { id: "gpt-5.1-chat-latest", releaseDate: "2025-11-13", efforts: ["medium"] }, + { id: "gpt-5.2-chat-latest", releaseDate: "2025-12-11", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: testCase.modelID ?? testCase.id, + providerID: "openai", + api: { + id: testCase.id, + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + release_date: testCase.releaseDate, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } + + test("gpt-50 (lookalike) does not get gpt-5 family treatment", () => { + const model = createMockModel({ + id: "gpt-50", + providerID: "openai", + api: { + id: "gpt-50", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + release_date: "2024-01-01", + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + }) + + describe("@ai-sdk/amazon-bedrock/mantle", () => { + test("gpt-5.5 returns OpenAI-style reasoning variants", () => { + const model = createMockModel({ + id: "openai.gpt-5.5", + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5.5", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + release_date: "2026-04-23", + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) + expect(result.medium).toEqual({ + reasoningEffort: "medium", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + }) + }) + + describe("@ai-sdk/anthropic", () => { + for (const testCase of [ + { + name: "opus 4 dated", + apiIds: ["claude-opus-4-20250514"], + efforts: ["high", "max"], + expectedHigh: { thinking: { type: "enabled", budgetTokens: 16000 } }, + }, + { + name: "opus 4.5", + apiIds: ["claude-opus-4-5-20251101", "claude-opus-4.5-20251101"], + efforts: ["low", "medium", "high"], + expectedHigh: { thinking: { type: "enabled", budgetTokens: 16000 }, effort: "high" }, + }, + { + name: "sonnet 4.6", + apiIds: ["claude-sonnet-4-6", "claude-sonnet-4.6"], + efforts: ["low", "medium", "high", "max"], + expectedHigh: { thinking: { type: "adaptive" }, effort: "high" }, + }, + { + name: "opus 4.6", + apiIds: ["claude-opus-4-6", "claude-opus-4.6"], + efforts: ["low", "medium", "high", "max"], + expectedHigh: { thinking: { type: "adaptive" }, effort: "high" }, + }, + { + name: "opus 4.7", + apiIds: ["claude-opus-4-7", "claude-opus-4.7"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + { + name: "opus 4.8", + apiIds: ["claude-opus-4-8", "claude-opus-4.8"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + { + name: "sonnet 5", + apiIds: ["claude-sonnet-5", "claude-sonnet-5-20260630"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + { + name: "opus 5", + apiIds: ["claude-opus-5", "claude-opus-5-20260724"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + { + name: "unversioned future model", + apiIds: ["claude-future"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + { + name: "fable 5", + apiIds: ["claude-fable-5"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + ]) { + for (const apiId of testCase.apiIds) { + test(`${testCase.name} ${apiId} returns supported reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: `anthropic/${apiId}`, + providerID: "anthropic", + api: { + id: apiId, + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + expect(result.high).toEqual(testCase.expectedHigh) + }) + } + } + + test("github copilot opus 4.7 returns only medium reasoning effort", () => { + const model = createMockModel({ + id: "claude-opus-4.7", + providerID: "github-copilot", + api: { + id: "claude-opus-4.7", + url: "https://api.githubcopilot.com/v1", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({ + medium: { + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "medium", + }, + }) + }) + + test("returns high and max with thinking config", () => { + const model = createMockModel({ + id: "anthropic/claude-4", + providerID: "anthropic", + api: { + id: "claude-4", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["high", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "enabled", + budgetTokens: 16000, + }, + }) + expect(result.max).toEqual({ + thinking: { + type: "enabled", + budgetTokens: 31999, + }, + }) + }) + }) + + describe("@ai-sdk/google-vertex/anthropic", () => { + test("opus 4.8 uses adaptive reasoning for Vertex model IDs", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "google-vertex-anthropic/claude-opus-4-8@default", + providerID: "google-vertex-anthropic", + api: { + id: "claude-opus-4-8@default", + url: "https://us-central1-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) + + test("sonnet 5 uses adaptive reasoning for Vertex model IDs", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "google-vertex-anthropic/claude-sonnet-5@default", + providerID: "google-vertex-anthropic", + api: { + id: "claude-sonnet-5@default", + url: "https://us-central1-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) + + test("opus 5 uses adaptive reasoning for Vertex model IDs", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "google-vertex-anthropic/claude-opus-5@default", + providerID: "google-vertex-anthropic", + api: { + id: "claude-opus-5@default", + url: "https://us-central1-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) + }) + + describe("@ai-sdk/amazon-bedrock", () => { + test("anthropic sonnet 4.6 returns adaptive reasoning options", () => { + const model = createMockModel({ + id: "bedrock/anthropic-claude-sonnet-4-6", + providerID: "bedrock", + api: { + id: "anthropic.claude-sonnet-4-6", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + expect(result.max).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "max", + }, + }) + }) + + test("anthropic opus 4.7 returns adaptive reasoning options with xhigh", () => { + const model = createMockModel({ + id: "bedrock/anthropic-claude-opus-4-7", + providerID: "bedrock", + api: { + id: "anthropic.claude-opus-4-7", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.xhigh).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "xhigh", + display: "summarized", + }, + }) + expect(result.max).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "max", + display: "summarized", + }, + }) + }) + + test("anthropic opus 4.8 returns adaptive reasoning options with xhigh", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "bedrock/anthropic-claude-opus-4.8", + providerID: "bedrock", + api: { + id: "anthropic.claude-opus-4.8", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + + test("anthropic sonnet 5 returns adaptive reasoning options with xhigh", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "bedrock/anthropic-claude-sonnet-5", + providerID: "bedrock", + api: { + id: "anthropic.claude-sonnet-5", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + + test("anthropic opus 5 returns adaptive reasoning options with xhigh", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "bedrock/anthropic-claude-opus-5", + providerID: "bedrock", + api: { + id: "us.anthropic.claude-opus-5-v1:0", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + + test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => { + const model = createMockModel({ + id: "bedrock/llama-4", + providerID: "bedrock", + api: { + id: "llama-4-sc", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.low).toEqual({ + reasoningConfig: { + type: "enabled", + maxReasoningEffort: "low", + }, + }) + }) + }) + + for (const provider of [ + { name: "@ai-sdk/google", providerID: "google", url: "https://generativelanguage.googleapis.com" }, + { name: "@ai-sdk/google-vertex", providerID: "google-vertex", url: "https://vertexai.googleapis.com" }, + ]) { + describe(provider.name, () => { + for (const testCase of [ + { + apiId: "gemini-2.5-pro", + efforts: ["high", "max"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }, + expectedMax: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } }, + }, + { + apiId: "gemini-2.5-flash", + efforts: ["high", "max"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }, + expectedMax: { thinkingConfig: { includeThoughts: true, thinkingBudget: 24_576 } }, + }, + { + apiId: "gemini-3-pro-preview", + efforts: ["low", "medium", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + { + apiId: "gemini-3.1-pro-preview", + efforts: ["low", "medium", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + { + apiId: "gemini-3-flash-preview", + efforts: ["minimal", "low", "medium", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + { + apiId: "gemini-3.1-flash-lite", + efforts: ["minimal", "low", "medium", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + { + apiId: "gemini-3.1-flash-image-preview", + efforts: ["minimal", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + { + apiId: "gemini-3-pro-image-preview", + efforts: ["high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + ]) { + test(`${testCase.apiId} returns supported thinking controls`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: `${provider.providerID}/${testCase.apiId}`, + providerID: provider.providerID, + api: { + id: testCase.apiId, + url: provider.url, + npm: provider.name, + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + expect(result.high).toEqual(testCase.expectedHigh) + if (testCase.expectedMax) expect(result.max).toEqual(testCase.expectedMax) + }) + } + }) + } + + describe("@ai-sdk/cohere", () => { + test("returns empty object", () => { + const model = createMockModel({ + id: "cohere/command-r", + providerID: "cohere", + api: { + id: "command-r", + url: "https://api.cohere.com", + npm: "@ai-sdk/cohere", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + }) + + describe("@ai-sdk/groq", () => { + test("returns none and WIDELY_SUPPORTED_EFFORTS with thinkingLevel", () => { + const model = createMockModel({ + id: "groq/llama-4", + providerID: "groq", + api: { + id: "llama-4-sc", + url: "https://api.groq.com", + npm: "@ai-sdk/groq", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high"]) + expect(result.none).toEqual({ + reasoningEffort: "none", + }) + expect(result.low).toEqual({ + reasoningEffort: "low", + }) + }) + }) + + describe("@ai-sdk/perplexity", () => { + test("returns empty object", () => { + const model = createMockModel({ + id: "perplexity/sonar-plus", + providerID: "perplexity", + api: { + id: "sonar-plus", + url: "https://api.perplexity.ai", + npm: "@ai-sdk/perplexity", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + }) + + describe("@jerome-benoit/sap-ai-provider-v2", () => { + const sapModel = (apiId: string, releaseDate = "2024-01-01") => + createMockModel({ + id: `sap-ai-core/${apiId}`, + providerID: "sap-ai-core", + api: { + id: apiId, + url: "https://api.ai.sap", + npm: "@jerome-benoit/sap-ai-provider-v2", + }, + release_date: releaseDate, + }) + + for (const testCase of [ + { + name: "sonnet 4.6", + apiIds: ["anthropic--claude-sonnet-4-6"], + efforts: ["low", "medium", "high", "max"], + thinking: { type: "adaptive" }, + }, + { + name: "opus 4.6", + apiIds: ["anthropic--claude-4.6-opus", "anthropic--claude-4-6-opus"], + efforts: ["low", "medium", "high", "max"], + thinking: { type: "adaptive" }, + }, + { + name: "opus 4.7", + apiIds: ["anthropic--claude-4.7-opus", "anthropic--claude-4-7-opus"], + efforts: ["low", "medium", "high", "xhigh", "max"], + thinking: { type: "adaptive", display: "summarized" }, + }, + { + name: "opus 4.8", + apiIds: ["anthropic--claude-4.8-opus", "anthropic--claude-4-8-opus"], + efforts: ["low", "medium", "high", "xhigh", "max"], + thinking: { type: "adaptive", display: "summarized" }, + }, + { + name: "sonnet 5", + apiIds: ["anthropic--claude-sonnet-5", "anthropic--claude-5-sonnet"], + efforts: ["low", "medium", "high", "xhigh", "max"], + thinking: { type: "adaptive", display: "summarized" }, + }, + { + name: "opus 5", + apiIds: ["anthropic--claude-opus-5", "anthropic--claude-5-opus"], + efforts: ["low", "medium", "high", "xhigh", "max"], + thinking: { type: "adaptive", display: "summarized" }, + }, + ]) { + for (const apiId of testCase.apiIds) { + test(`${testCase.name} ${apiId} returns adaptive thinking variants under modelParams`, () => { + const result = ProviderTransform.variants(sapModel(apiId)) + expect(Object.keys(result)).toEqual(testCase.efforts) + for (const effort of testCase.efforts) { + expect(result[effort]).toEqual({ + modelParams: { + thinking: testCase.thinking, + output_config: { effort }, + }, + }) + } + }) + } + } + + for (const apiId of ["anthropic--claude-sonnet-4", "anthropic--claude-4.5-opus"]) { + test(`${apiId} returns budget_tokens variants under modelParams`, () => { + const result = ProviderTransform.variants(sapModel(apiId)) + expect(Object.keys(result)).toEqual(["high", "max"]) + expect(result.high).toEqual({ + modelParams: { thinking: { type: "enabled", budget_tokens: 16000 } }, + }) + expect(result.max).toEqual({ + modelParams: { thinking: { type: "enabled", budget_tokens: 31999 } }, + }) + }) + } + + for (const testCase of [ + { apiId: "gemini-2.5-pro", maxBudget: 32768 }, + { apiId: "gemini-2.5-flash", maxBudget: 24576 }, + ]) { + test(`${testCase.apiId} returns thinkingConfig variants under modelParams`, () => { + const result = ProviderTransform.variants(sapModel(testCase.apiId)) + expect(Object.keys(result)).toEqual(["high", "max"]) + expect(result.high).toEqual({ + modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } }, + }) + expect(result.max).toEqual({ + modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: testCase.maxBudget } }, + }) + }) + } + + for (const testCase of [ + { apiId: "gpt-5", releaseDate: "2025-08-07", efforts: ["minimal", "low", "medium", "high"] }, + { apiId: "gpt-5-mini", releaseDate: "2025-08-07", efforts: ["minimal", "low", "medium", "high"] }, + { apiId: "gpt-5-nano", releaseDate: "2025-08-07", efforts: ["minimal", "low", "medium", "high"] }, + { apiId: "gpt-5.4", releaseDate: "2026-01-15", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { apiId: "azure-openai--o3-mini", releaseDate: "2024-01-01", efforts: ["low", "medium", "high"] }, + ]) { + test(`${testCase.apiId} returns reasoning_effort variants under modelParams`, () => { + const result = ProviderTransform.variants(sapModel(testCase.apiId, testCase.releaseDate)) + expect(Object.keys(result)).toEqual(testCase.efforts) + for (const effort of testCase.efforts) { + expect(result[effort]).toEqual({ modelParams: { reasoning_effort: effort } }) + } + }) + } + + for (const apiId of [ + "gemini-3.1-flash-lite", + "cohere--command-a-reasoning", + "sonar-deep-research", + "aws--llama-opus-4.7-fake", + ]) { + test(`${apiId} falls through to harmonized reasoning_effort fallback`, () => { + const result = ProviderTransform.variants(sapModel(apiId)) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + for (const effort of ["low", "medium", "high"]) { + expect(result[effort]).toEqual({ modelParams: { reasoning_effort: effort } }) + } + }) + } + }) + + describe("ai-gateway-provider (cloudflare-ai-gateway)", () => { + const cfModel = (apiId: string, releaseDate = "2024-01-01") => + createMockModel({ + id: `cloudflare-ai-gateway/${apiId}`, + providerID: "cloudflare-ai-gateway", + api: { + id: apiId, + url: "https://gateway.ai.cloudflare.com/v1/compat", + npm: "ai-gateway-provider", + }, + release_date: releaseDate, + }) + + for (const testCase of [ + { id: "openai/gpt-5.4", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.2-codex", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.3-codex", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-pro", efforts: ["high"] }, + { id: "openai/gpt-5.2-pro", efforts: ["medium", "high", "xhigh"] }, + { id: "openai/gpt-5-chat-latest", efforts: [] }, + { id: "openai/gpt-5.2-chat-latest", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported reasoning efforts`, () => { + const result = ProviderTransform.variants(cfModel(testCase.id, "2026-03-05")) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } + + test("openai gpt-4o (no reasoning) returns empty", () => { + const model = cfModel("openai/gpt-4o") + model.capabilities.reasoning = false + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("non-openai upstream falls back to widely-supported OAI efforts", () => { + const result = ProviderTransform.variants(cfModel("anthropic/claude-sonnet-4-6")) + expect(result).toEqual({ + low: { reasoningEffort: "low" }, + medium: { reasoningEffort: "medium" }, + high: { reasoningEffort: "high" }, + }) + }) + }) +}) + +describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => { + const createModel = (apiId: string) => { + const model = { + id: `openai/${apiId}`, + providerID: "openai", + api: { + id: apiId, + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + capabilities: { reasoning: true }, + limit: { output: 64_000 }, + release_date: "2026-01-01", + } as any + model.variants = ProviderTransform.variants(model) + return model + } + + for (const testCase of [ + { id: "gpt-5-chat-latest", options: { store: false } }, + { + id: "gpt-5.1-chat-latest", + options: { + store: false, + reasoningEffort: "medium", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }, + { + id: "gpt-5.2-chat-latest", + options: { + store: false, + reasoningEffort: "medium", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }, + { + id: "gpt-5-search-api", + options: { + store: false, + reasoningEffort: "none", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }, + ]) { + test(`${testCase.id} returns only supported small options`, () => { + expect(ProviderTransform.smallOptions(createModel(testCase.id))).toEqual(testCase.options) + }) + } +}) + +test("ProviderTransform.smallOptions preserves the weakest OpenRouter reasoning effort", () => { + expect( + ProviderTransform.smallOptions({ + providerID: "openrouter", + api: { + id: "google/gemini-3.5-flash", + npm: "@openrouter/ai-sdk-provider", + }, + variants: { + low: { reasoning: { effort: "low" } }, + medium: { reasoning: { effort: "medium" } }, + high: { reasoning: { effort: "high" } }, + }, + } as any), + ).toEqual({ reasoning: { effort: "low" } }) +}) + +describe("ProviderTransform.smallOptions - google thinking controls", () => { + const createGoogleModel = (apiId: string) => { + const model = { + id: `google/${apiId}`, + providerID: "google", + api: { + id: apiId, + url: "https://generativelanguage.googleapis.com", + npm: "@ai-sdk/google", + }, + capabilities: { reasoning: true }, + limit: { output: 64_000 }, + } as any + model.variants = ProviderTransform.variants(model) + return model + } + + for (const testCase of [ + { id: "gemini-3-pro-preview", options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "low" } } }, + { id: "gemini-3-flash-preview", options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "minimal" } } }, + { + id: "gemini-3.1-flash-image-preview", + options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "minimal" } }, + }, + { id: "gemini-3-pro-image-preview", options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } } }, + { id: "gemini-2.5-pro", options: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } }, + { id: "gemini-2.5-flash", options: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } }, + ]) { + test(`${testCase.id} returns supported small thinking options`, () => { + expect(ProviderTransform.smallOptions(createGoogleModel(testCase.id))).toEqual(testCase.options) + }) + } + + test("uses the first configured variant when available", () => { + expect( + ProviderTransform.smallOptions({ + ...createGoogleModel("gemini-2.5-pro"), + variants: { + high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } }, + max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32768 } }, + }, + }), + ).toEqual({ thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } }) + }) + + test("does not synthesize thinking options when variants are empty", () => { + expect(ProviderTransform.smallOptions({ ...createGoogleModel("gemini-2.5-pro"), variants: {} })).toEqual({}) + }) +}) + +describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { + const createModel = (overrides: Partial = {}) => + ({ + id: "cloudflare-ai-gateway/openai/gpt-5.4", + providerID: "cloudflare-ai-gateway", + api: { + id: "openai/gpt-5.4", + url: "https://gateway.ai.cloudflare.com/v1/compat", + npm: "ai-gateway-provider", + }, + capabilities: { + temperature: false, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 1, output: 1, cache: { read: 0, write: 0 } }, + limit: { context: 1_000_000, output: 128_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-03-05", + ...overrides, + }) as any + + test("routes options under openaiCompatible (the key @ai-sdk/openai-compatible reads)", () => { + // Regression: previously fell back to providerID="cloudflare-ai-gateway", + // which @ai-sdk/openai-compatible never reads, silently dropping reasoningEffort. + const result = ProviderTransform.providerOptions(createModel(), { reasoningEffort: "high" }) + expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } }) + }) +}) + +describe("ProviderTransform.providerOptions - merge-gateway-ai-sdk-provider", () => { + const model = { + id: "merge-gateway/openai/gpt-5.6-sol", + providerID: "merge-gateway", + api: { + id: "openai/gpt-5.6-sol", + url: "https://api-gateway.merge.dev/v1/ai-sdk", + npm: "merge-gateway-ai-sdk-provider", + }, + capabilities: { reasoning: true }, + } as any + + test("routes normalized effort under the adapter's mergeGateway key", () => { + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "high" })).toEqual({ + mergeGateway: { reasoningEffort: "high" }, + }) + }) +}) + +describe("ProviderTransform.options - kimi family adaptive thinking", () => { + const createModel = (overrides: Record = {}) => + ({ + id: "moonshotai/kimi-k2-thinking", + providerID: "moonshotai", + api: { + id: "kimi-k2-thinking", + url: "https://api.moonshot.ai/anthropic", + npm: "@ai-sdk/anthropic", + }, + name: "Kimi K2 Thinking", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, + limit: { context: 262144, output: 262144 }, + status: "active", + options: {}, + headers: {}, + ...overrides, + }) as any + + test("uses adaptive thinking with effort instead of budget tokens", () => { + const result = ProviderTransform.options({ model: createModel(), sessionID: "s1", providerOptions: {} }) + expect(result.thinking).toEqual({ type: "adaptive", display: "summarized" }) + expect(result.effort).toBe("high") + expect(JSON.stringify(result)).not.toContain("budgetTokens") + }) + + test("uses adaptive thinking through Google Vertex Anthropic", () => { + const model = createModel() + model.api.npm = "@ai-sdk/google-vertex/anthropic" + const result = ProviderTransform.options({ model, sessionID: "s1", providerOptions: {} }) + expect(result.thinking).toEqual({ type: "adaptive", display: "summarized" }) + expect(result.effort).toBe("high") + }) + + test("provides adaptive effort variants without models.dev metadata", () => { + expect(ProviderTransform.variants(createModel())).toEqual( + Object.fromEntries( + ["low", "medium", "high", "xhigh", "max"].map((effort) => [ + effort, + { thinking: { type: "adaptive", display: "summarized" }, effort }, + ]), + ), + ) + + const model = createModel() + model.api.npm = "@ai-sdk/openai-compatible" + expect(ProviderTransform.variants(model)).toEqual({}) + }) + + test("does not enable thinking for kimi models without reasoning capability", () => { + const model = createModel() + model.capabilities.reasoning = false + const result = ProviderTransform.options({ model, sessionID: "s1", providerOptions: {} }) + expect(result.thinking).toBeUndefined() + }) + + test("does not set thinking defaults for non-kimi anthropic models", () => { + const model = createModel({ + id: "anthropic/claude-sonnet-4-5", + providerID: "anthropic", + api: { + id: "claude-sonnet-4-5", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.options({ model, sessionID: "s1", providerOptions: {} }) + expect(result.thinking).toBeUndefined() + expect(result.effort).toBeUndefined() + }) + + test("does not set adaptive thinking for kimi on openai-compatible", () => { + const model = createModel() + model.api = { + id: "kimi-k2-thinking", + url: "https://api.moonshot.ai/v1", + npm: "@ai-sdk/openai-compatible", + } + const result = ProviderTransform.options({ model, sessionID: "s1", providerOptions: {} }) + expect(result.thinking).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/server/AGENTS.md b/packages/opencode/test/server/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..754977375b0e22713e977d22eedad9db799bcda6 --- /dev/null +++ b/packages/opencode/test/server/AGENTS.md @@ -0,0 +1,15 @@ +# Server Test Guide + +Use these patterns for server and HttpApi middleware tests in this directory. + +- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy. +- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it. +- Use tiny `HttpApiBuilder` probe groups that declare the typed middleware under test and expose context such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. +- Declare middleware in the same order as production when testing interactions, for example `InstanceContextMiddleware` followed by `WorkspaceRoutingMiddleware`. +- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits. +- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific. +- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant. +- Use scoped test layers for flags, database reset, and other global mutable state. Restore flags and reset state in finalizers. +- Use `tmpdirScoped({ git: true })` plus `Project.use.fromDirectory(dir)` for project-backed requests. +- If a test needs persisted state without matching runtime state, keep direct database setup inside a narrowly named helper that explains that state. +- Add comments for non-obvious test topology, especially tests involving both the local test server and a fake upstream server. diff --git a/packages/opencode/test/server/auth.test.ts b/packages/opencode/test/server/auth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1278e8c72e8c93a26b2f04640949f44950f8e8f3 --- /dev/null +++ b/packages/opencode/test/server/auth.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Option, Redacted } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "../../src/server/auth" + +const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +afterEach(() => { + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME +}) + +describe("ServerAuth", () => { + test("does not emit auth headers without a password", () => { + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.header()).toBeUndefined() + expect(ServerAuth.headers()).toBeUndefined() + }) + + test("defaults to the opencode username", () => { + Flag.OPENCODE_SERVER_PASSWORD = "secret" + Flag.OPENCODE_SERVER_USERNAME = undefined + + expect(ServerAuth.headers()).toEqual({ + Authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`, + }) + }) + + test("uses the configured username", () => { + Flag.OPENCODE_SERVER_PASSWORD = "secret" + Flag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.headers()).toEqual({ + Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`, + }) + }) + + test("prefers explicit credentials", () => { + Flag.OPENCODE_SERVER_PASSWORD = "secret" + Flag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({ + Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`, + }) + }) + + test("validates decoded credentials against effect config", () => { + const config = { password: Option.some("secret"), username: "alice" } + + expect(ServerAuth.required(config)).toBe(true) + expect(ServerAuth.authorized({ username: "alice", password: Redacted.make("secret") }, config)).toBe(true) + expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(false) + }) +}) diff --git a/packages/opencode/test/server/global-bus.ts b/packages/opencode/test/server/global-bus.ts new file mode 100644 index 0000000000000000000000000000000000000000..24e5cf77b5a97915a70e459df9a9bc592f0b80fa --- /dev/null +++ b/packages/opencode/test/server/global-bus.ts @@ -0,0 +1,31 @@ +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Cause, Effect } from "effect" + +export function waitGlobalBusEvent(input: { + timeout?: number + message?: string + predicate: (event: GlobalEvent) => boolean +}) { + return Effect.callback((resume) => { + const cleanup = () => GlobalBus.off("event", handler) + + const handler = (event: GlobalEvent) => { + try { + if (!input.predicate(event)) return + cleanup() + resume(Effect.succeed(event)) + } catch (error) { + cleanup() + resume(Effect.fail(error)) + } + } + + GlobalBus.on("event", handler) + return Effect.sync(cleanup) + }).pipe( + Effect.timeout(input.timeout ?? 10_000), + Effect.mapError((error) => + Cause.isTimeoutError(error) ? new Error(input.message ?? "timed out waiting for global bus event") : error, + ), + ) +} diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a13553af61839f57560642151c7b1a3b422349f --- /dev/null +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -0,0 +1,108 @@ +import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { Deferred, Effect, Layer } from "effect" +import { Project } from "@/project/project" +import { Session as SessionNs } from "@/session/session" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect( + LayerNode.compile(LayerNode.group([SessionNs.node, SessionProjector.node, Project.node, CrossSpawnSpawner.node])), +) + +const withSession = (input?: Parameters[0]) => + Effect.acquireRelease(SessionNs.use.create(input), (created) => + SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)), + ) + +describe("session.listGlobal", () => { + it.instance( + "lists sessions across projects with project metadata", + () => + Effect.gen(function* () { + const first = yield* TestInstance + const second = yield* tmpdirScoped({ git: true }) + + const firstSession = yield* withSession({ title: "first-session" }) + const secondSession = yield* withSession({ title: "second-session" }).pipe(provideInstance(second)) + + const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 })) + const ids = sessions.map((session) => session.id) + + expect(ids).toContain(firstSession.id) + expect(ids).toContain(secondSession.id) + + const firstProject = yield* Project.use.get(firstSession.projectID) + const secondProject = yield* Project.use.get(secondSession.projectID) + + const firstItem = sessions.find((session) => session.id === firstSession.id) + const secondItem = sessions.find((session) => session.id === secondSession.id) + + expect(firstItem?.project?.id).toBe(firstProject?.id) + expect(firstItem?.project?.worktree).toBe(firstProject?.worktree) + expect(secondItem?.project?.id).toBe(secondProject?.id) + expect(secondItem?.project?.worktree).toBe(secondProject?.worktree) + expect(first.directory).not.toBe(second) + }), + { git: true }, + ) + + it.instance( + "excludes archived sessions by default", + () => + Effect.gen(function* () { + const archived = yield* withSession({ title: "archived-session" }) + + yield* SessionNs.Service.use((session) => session.setArchived({ sessionID: archived.id, time: Date.now() })) + + const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 })) + const ids = sessions.map((session) => session.id) + + expect(ids).not.toContain(archived.id) + + const allSessions = yield* SessionNs.Service.use((session) => + session.listGlobal({ limit: 200, archived: true }), + ) + const allIds = allSessions.map((session) => session.id) + + expect(allIds).toContain(archived.id) + }), + { git: true }, + ) + + it.instance( + "supports cursor pagination", + () => + Effect.gen(function* () { + const test = yield* TestInstance + + const first = yield* withSession({ title: "page-one" }) + const ready = yield* Deferred.make() + yield* Deferred.succeed(ready, undefined).pipe(Effect.delay("5 millis"), Effect.forkScoped) + yield* Deferred.await(ready).pipe( + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => Effect.fail(new Error("timed out waiting between session creates")), + }), + ) + const second = yield* withSession({ title: "page-two" }) + + const page = yield* SessionNs.Service.use((session) => + session.listGlobal({ directory: test.directory, limit: 1 }), + ) + expect(page.length).toBe(1) + expect(page[0].id).toBe(second.id) + + const next = yield* SessionNs.Service.use((session) => + session.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }), + ) + const ids = next.map((session) => session.id) + + expect(ids).toContain(first.id) + expect(ids).not.toContain(second.id) + }), + { git: true }, + ) +}) diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..211b2e188472cd926c8daf86b876cb111f31a25e --- /dev/null +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -0,0 +1,174 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Effect, Layer, Option, Schema } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi" +import { ServerAuth } from "../../src/server/auth" +import { + Authorization, + authorizationLayer, + ServerAuthorization, + serverAuthorizationLayer, +} from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { testEffect } from "../lib/effect" + +const Api = HttpApi.make("test-authorization").add( + HttpApiGroup.make("test") + .add( + HttpApiEndpoint.get("probe", "/probe", { + success: Schema.String, + }), + HttpApiEndpoint.get("missing", "/missing", { + success: Schema.String, + error: HttpApiError.NotFound, + }), + ) + .middleware(Authorization), +) + +const ServerApi = HttpApi.make("test-server-authorization").add( + HttpApiGroup.make("test.v2") + .add( + HttpApiEndpoint.get("probe", "/api/probe", { + success: Schema.String, + }), + ) + .middleware(ServerAuthorization), +) + +const handlers = HttpApiBuilder.group(Api, "test", (handlers) => + handlers + .handle("probe", () => Effect.succeed("ok")) + .handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))), +) + +const serverHandlers = HttpApiBuilder.group(ServerApi, "test.v2", (handlers) => + handlers.handle("probe", () => Effect.succeed("ok")), +) + +const apiLayer = HttpRouter.serve( + HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)), + { disableListenLog: true, disableLogger: true }, +).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) + +const v2ApiLayer = HttpRouter.serve( + HttpApiBuilder.layer(ServerApi).pipe(Layer.provide(serverHandlers), Layer.provide(serverAuthorizationLayer)), + { disableListenLog: true, disableLogger: true }, +).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) + +const noAuthLayer = ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" }) +const secretLayer = ServerAuth.Config.configLayer({ password: Option.some("secret"), username: "opencode" }) +const kitSecretLayer = ServerAuth.Config.configLayer({ password: Option.some("secret"), username: "kit" }) + +const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer))) +const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer))) +const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer))) +const itV2Secret = testEffect(v2ApiLayer.pipe(Layer.provide(secretLayer))) + +const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? "" + +const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64") + +const getProbe = (headers?: Record) => + HttpClientRequest.get("/probe").pipe( + headers ? HttpClientRequest.setHeaders(headers) : (request) => request, + HttpClient.execute, + ) + +describe("HttpApi authorization middleware", () => { + it.live("allows requests when server password is not configured", () => + Effect.gen(function* () { + const response = yield* getProbe() + + expect(response.status).toBe(200) + expect(yield* response.json).toBe("ok") + }), + ) + + itSecret.live("requires configured password for basic auth", () => + Effect.gen(function* () { + const [missing, badPassword, good] = yield* Effect.all( + [ + getProbe(), + getProbe({ authorization: basic("opencode", "wrong") }), + getProbe({ authorization: basic("opencode", "secret") }), + ], + { concurrency: "unbounded" }, + ) + + expect(missing.status).toBe(401) + expect(missing.headers["www-authenticate"] ?? "").toContain("Basic") + expect(badPassword.status).toBe(401) + expect(badPassword.headers["www-authenticate"] ?? "").toContain("Basic") + expect(good.status).toBe(200) + }), + ) + + itKitSecret.live("respects configured basic auth username", () => + Effect.gen(function* () { + const [defaultUser, configuredUser] = yield* Effect.all( + [getProbe({ authorization: basic("opencode", "secret") }), getProbe({ authorization: basic("kit", "secret") })], + { concurrency: "unbounded" }, + ) + + expect(defaultUser.status).toBe(401) + expect(configuredUser.status).toBe(200) + }), + ) + + itSecret.live("accepts auth token query credentials", () => + Effect.gen(function* () { + const response = yield* HttpClient.get(`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`) + + expect(response.status).toBe(200) + }), + ) + + itSecret.live("prefers auth token query credentials over basic auth", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.get( + `/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`, + ).pipe(HttpClientRequest.setHeader("authorization", basic("opencode", "wrong")), HttpClient.execute) + + expect(response.status).toBe(200) + }), + ) + + itSecret.live("preserves handler errors when basic auth succeeds", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.get("/missing").pipe( + HttpClientRequest.setHeader("authorization", basic("opencode", "secret")), + HttpClient.execute, + ) + + expect(response.status).toBe(404) + }), + ) + + itSecret.live("preserves handler errors when auth token query succeeds", () => + Effect.gen(function* () { + const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("opencode", "secret"))}`) + + expect(response.status).toBe(404) + }), + ) + + itSecret.live("rejects malformed auth token query credentials", () => + Effect.gen(function* () { + const response = yield* HttpClient.get("/probe?auth_token=not-base64") + + expect(response.status).toBe(401) + }), + ) + + itV2Secret.live("returns bodyful v2 unauthorized errors", () => + Effect.gen(function* () { + const response = yield* HttpClient.get("/api/probe") + const body = yield* response.json + + expect(response.status).toBe(401) + expect(response.headers["www-authenticate"] ?? "").toContain("Basic") + expect(body).toEqual({ _tag: "UnauthorizedError", message: "Authentication required" }) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-compression.test.ts b/packages/opencode/test/server/httpapi-compression.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..24ec8b6843caa79c856ea993ab14290621a271d8 --- /dev/null +++ b/packages/opencode/test/server/httpapi-compression.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { gunzipSync, inflateSync } from "node:zlib" +import { Server } from "../../src/server/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function app() { + return Server.Default().app +} + +// /config echoes the config back. Padding the config pushes the response body +// well past the 1024 B threshold so we can observe compression behavior. +function fatConfig() { + const instructions: string[] = [] + for (let i = 0; i < 50; i++) { + instructions.push(`padding-instruction-${i}-${"x".repeat(40)}`) + } + return { + formatter: false, + lsp: false, + username: "compression-test-user", + instructions, + } +} + +describe("HttpApi compression", () => { + describe("encodes responses", () => { + test("gzips JSON when Accept-Encoding includes gzip and body exceeds threshold", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBe("gzip") + const compressed = new Uint8Array(await response.arrayBuffer()) + const decompressed = gunzipSync(compressed) + const json = JSON.parse(new TextDecoder().decode(decompressed)) + expect(json).toMatchObject({ username: "compression-test-user" }) + expect(compressed.byteLength).toBeLessThan(decompressed.byteLength) + }) + + test("uses deflate when only deflate is acceptable", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "deflate" }, + }) + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBe("deflate") + const compressed = new Uint8Array(await response.arrayBuffer()) + const decompressed = inflateSync(compressed) + const json = JSON.parse(new TextDecoder().decode(decompressed)) + expect(json).toMatchObject({ username: "compression-test-user" }) + }) + + test("prefers gzip when both gzip and deflate are acceptable", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip, deflate" }, + }) + expect(response.headers.get("content-encoding")).toBe("gzip") + }) + + test("does not include the original Content-Length when compressed", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + const compressed = new Uint8Array(await response.arrayBuffer()) + const declared = response.headers.get("content-length") + // Either absent (transfer-encoding chunked) or matches the compressed length. + if (declared !== null) expect(Number(declared)).toBe(compressed.byteLength) + }) + }) + + describe("skips", () => { + test("when no Accept-Encoding header is present", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path }, + }) + expect(response.headers.get("content-encoding")).toBeNull() + }) + + test("when Accept-Encoding only allows unsupported encodings", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "br" }, + }) + expect(response.headers.get("content-encoding")).toBeNull() + }) + + test("when the response body is below the 1024-byte threshold", async () => { + // A bare config produces a tiny response (~few hundred bytes). + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + expect(response.status).toBe(200) + const body = new Uint8Array(await response.arrayBuffer()) + expect(body.byteLength).toBeLessThan(1024) + expect(response.headers.get("content-encoding")).toBeNull() + }) + + test("HEAD requests", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + method: "HEAD", + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + expect(response.headers.get("content-encoding")).toBeNull() + }) + }) + + describe("streaming exclusions", () => { + test("/event SSE is not compressed", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const controller = new AbortController() + const response = await app().request("/event", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + signal: controller.signal, + }) + try { + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBeNull() + } finally { + controller.abort() + await response.body?.cancel().catch(() => {}) + } + }) + + test("/global/event SSE is not compressed", async () => { + const controller = new AbortController() + const response = await app().request("/global/event", { + headers: { "accept-encoding": "gzip" }, + signal: controller.signal, + }) + try { + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBeNull() + } finally { + controller.abort() + await response.body?.cancel().catch(() => {}) + } + }) + }) +}) diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..91281b455ff566a265ba65e91a908da170368d20 --- /dev/null +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { Server } from "../../src/server/server" +import { Effect, Fiber } from "effect" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" +import { waitGlobalBusEvent } from "./global-bus" + +function app() { + return Server.Default().app +} + +function waitDisposed(directory: string) { + return waitGlobalBusEvent({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, + }) +} + +const tmpdirEffect = (options: Parameters[0]) => + Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("config HttpApi", () => { + it.live( + "serves config update through the default server app", + Effect.gen(function* () { + const tmp = yield* tmpdirEffect({ config: { formatter: false, lsp: false } }) + const disposed = yield* waitDisposed(tmp.path).pipe(Effect.forkScoped({ startImmediately: true })) + + const response = yield* Effect.promise(() => + Promise.resolve( + app().request("/config", { + method: "PATCH", + headers: { + "content-type": "application/json", + "x-opencode-directory": tmp.path, + }, + body: JSON.stringify({ username: "patched-user", formatter: false, lsp: false }), + }), + ), + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + username: "patched-user", + formatter: false, + lsp: false, + }) + yield* Fiber.join(disposed) + expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "config.json")).json())).toMatchObject({ + username: "patched-user", + formatter: false, + lsp: false, + }) + }), + ) + + it.live( + "serves config with active provider model status", + Effect.gen(function* () { + const tmp = yield* tmpdirEffect({ + config: { + formatter: false, + lsp: false, + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }, + }) + + const response = yield* Effect.promise(() => + Promise.resolve( + app().request("/config", { + headers: { + "x-opencode-directory": tmp.path, + }, + }), + ), + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-control-plane.test.ts b/packages/opencode/test/server/httpapi-control-plane.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b837bf7556daf7a8fe436535d7605816fa8d2618 --- /dev/null +++ b/packages/opencode/test/server/httpapi-control-plane.test.ts @@ -0,0 +1,63 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Context, Effect, Layer, Option, Ref } from "effect" +import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { MoveSession } from "@opencode-ai/core/control-plane/move-session" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { Auth } from "../../src/auth" +import { Config } from "../../src/config/config" +import { Installation } from "../../src/installation" +import { ServerAuth } from "../../src/server/auth" +import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api" +import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control" +import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane" +import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global" +import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error" +import { testEffect } from "../lib/effect" + +const input = MoveSession.Input.make({ + sessionID: SessionV2.ID.make("ses_move"), + destination: { directory: AbsolutePath.make("/destination") }, + moveChanges: true, +}) +const called = Ref.makeUnsafe(undefined) + +const apiLayer = HttpRouter.serve( + HttpApiBuilder.layer(RootHttpApi).pipe( + Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]), + Layer.provide([authorizationLayer, schemaErrorLayer]), + // Raw HttpApi routes expose an opaque handler context at the request boundary. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context)), + ), + { disableListenLog: true, disableLogger: true }, +).pipe( + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provide(Layer.mock(Auth.Service)({})), + Layer.provide(Layer.mock(Config.Service)({})), + Layer.provide(Layer.mock(Installation.Service)({})), + Layer.provide( + Layer.mock(MoveSession.Service)({ + moveSession: (value) => Ref.set(called, value), + }), + ), + Layer.provide(ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" })), +) +const it = testEffect(apiLayer) + +describe("control-plane HttpApi", () => { + it.live("moves a session through the root control-plane route", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.post("/experimental/control-plane/move-session").pipe( + HttpClientRequest.setBody(HttpBody.jsonUnsafe(input)), + HttpClient.execute, + ) + + expect(response.status).toBe(204) + expect(yield* Ref.get(called)).toEqual(input) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-cors-vary.test.ts b/packages/opencode/test/server/httpapi-cors-vary.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..15af5a1a9738645dc7069f809c468b127b603a19 --- /dev/null +++ b/packages/opencode/test/server/httpapi-cors-vary.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Server } from "../../src/server/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances } from "../fixture/fixture" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function app() { + return Server.Default().app +} + +const PREFLIGHT_HEADERS = { + origin: "http://localhost:3000", + "access-control-request-method": "POST", + "access-control-request-headers": "content-type, x-opencode-directory", +} + +// effect-smol's HttpMiddleware.cors overwrites `Vary: Origin` with +// `Vary: Access-Control-Request-Headers` on OPTIONS preflight responses +// (the two share the same record key during the spread). With dynamic +// origin echoing, missing Vary: Origin lets shared caches serve a preflight +// cached for one origin against a different origin. corsVaryFixLayer +// restores the merged form. +describe("CORS preflight Vary header", () => { + test("HTTP API backend preflight Vary contains Origin", async () => { + const response = await app().request("/global/config", { + method: "OPTIONS", + headers: PREFLIGHT_HEADERS, + }) + + expect([200, 204]).toContain(response.status) + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") + expect((response.headers.get("vary") ?? "").toLowerCase()).toContain("origin") + }) + + test("HTTP API backend preflight Vary still preserves Access-Control-Request-Headers", async () => { + const response = await app().request("/global/config", { + method: "OPTIONS", + headers: PREFLIGHT_HEADERS, + }) + + const vary = (response.headers.get("vary") ?? "").toLowerCase() + expect(vary).toContain("origin") + expect(vary).toContain("access-control-request-headers") + }) + + test("HTTP API backend does not duplicate Origin in Vary", async () => { + const response = await app().request("/global/config", { + method: "OPTIONS", + headers: PREFLIGHT_HEADERS, + }) + + const vary = response.headers.get("vary") ?? "" + const originCount = vary + .split(",") + .map((s: string) => s.trim().toLowerCase()) + .filter((s: string) => s === "origin").length + expect(originCount).toBe(1) + }) +}) diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e9680c7ce4b6e8957125918a92bdf9129bbf1dd --- /dev/null +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -0,0 +1,122 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Config, ConfigProvider, Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { Server } from "../../src/server/server" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { resetDatabase } from "../fixture/db" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + } + Flag.OPENCODE_SERVER_PASSWORD = "secret" + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + await resetDatabase() + }), + ) + }), +) + +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { disableListenLog: true, disableLogger: true }, +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), + ), + ), +) + +describe("HttpApi CORS", () => { + it.live("allows browser preflight requests without credentials", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.options(InstancePaths.path).pipe( + HttpClientRequest.setHeaders({ + origin: "http://localhost:3000", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization", + }), + HttpClient.execute, + ) + + expect(response.status).toBe(204) + expect(response.headers["access-control-allow-origin"]).toBe("http://localhost:3000") + expect(response.headers["access-control-allow-headers"]).toBe("authorization") + }), + ) + + it.live("adds CORS headers to unauthorized responses", () => + Effect.gen(function* () { + const handler = HttpRouter.toWebHandler( + HttpApiApp.createRoutes().pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: "secret" }))), + ), + { disableLogger: true }, + ).handler + const response = yield* Effect.promise(() => + handler( + new Request(new URL("/global/config", "http://localhost"), { + headers: { origin: "https://app.opencode.ai" }, + }), + HttpApiApp.context, + ), + ) + + expect(response.status).toBe(401) + expect(response.headers.get("access-control-allow-origin")).toBe("https://app.opencode.ai") + }), + ) + + it.live("uses custom CORS origins passed to the server", () => + Effect.gen(function* () { + const listener = yield* Effect.acquireRelease( + Effect.promise(() => Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] })), + (listener) => Effect.promise(() => listener.stop(true)), + ) + + const response = yield* Effect.promise(() => + fetch(new URL(InstancePaths.path, listener.url), { + method: "OPTIONS", + headers: { + origin: "https://custom.example", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization", + }, + }), + ) + + expect(response.status).toBe(204) + expect(response.headers.get("access-control-allow-origin")).toBe("https://custom.example") + expect(response.headers.get("access-control-allow-headers")).toBe("authorization") + + const rejected = yield* Effect.promise(() => + fetch(new URL(InstancePaths.path, listener.url), { + method: "OPTIONS", + headers: { + origin: "https://evil.example", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization", + }, + }), + ) + + expect(rejected.status).toBe(204) + expect(rejected.headers.get("access-control-allow-origin")).not.toBe("https://evil.example") + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-error-middleware.test.ts b/packages/opencode/test/server/httpapi-error-middleware.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..525bf34e205f4651c459e381df93dca2906e33fd --- /dev/null +++ b/packages/opencode/test/server/httpapi-error-middleware.test.ts @@ -0,0 +1,122 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { NamedError } from "@opencode-ai/core/util/error" +import { describe, expect } from "bun:test" +import { ConfigErrorV1 } from "@opencode-ai/core/v1/config/error" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" +import { errorLayer } from "../../src/server/routes/instance/httpapi/middleware/error" +import { NotFoundError } from "../../src/storage/storage" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer)) + +function expectUnknownErrorBody(body: unknown) { + expect(body).toMatchObject({ + name: "UnknownError", + data: { message: "Unexpected server error. Check server logs for details." }, + }) + expect((body as { data?: { ref?: unknown } }).data?.ref).toMatch(/^err_[0-9a-f-]{8}$/) +} + +describe("HttpApi error middleware", () => { + it.live("returns a safe body for unknown 500 defects", () => + Effect.gen(function* () { + yield* HttpRouter.add("GET", "/boom", Effect.die(new Error("secret stack marker"))).pipe( + Layer.provide(errorLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClientRequest.get("/boom").pipe(HttpClient.execute) + const body = yield* response.json + + expect(response.status).toBe(500) + expectUnknownErrorBody(body) + expect(JSON.stringify(body)).not.toContain("secret stack marker") + }), + ) + + it.live("returns a safe body for named defects", () => + Effect.gen(function* () { + yield* HttpRouter.add( + "GET", + "/named", + Effect.die(new NamedError.Unknown({ message: "secret named marker" })), + ).pipe(Layer.provide(errorLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClientRequest.get("/named").pipe(HttpClient.execute) + const body = yield* response.json + + expect(response.status).toBe(500) + expectUnknownErrorBody(body) + expect(JSON.stringify(body)).not.toContain("secret named marker") + }), + ) + + it.live("returns invalid config defects as structured client errors", () => + Effect.gen(function* () { + const configError = new ConfigErrorV1.InvalidError({ + path: "/tmp/opencode.json", + issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }], + }) + + yield* HttpRouter.add("GET", "/config-error", Effect.die(configError)).pipe( + Layer.provide(errorLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClientRequest.get("/config-error").pipe(HttpClient.execute) + const body = yield* response.json + const serialized = JSON.stringify(body) + + expect(response.status).toBe(400) + expect(body).toMatchObject({ + name: "ConfigInvalidError", + data: { + path: "/tmp/opencode.json", + issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }], + }, + }) + expect(serialized).toContain("/tmp/opencode.json") + expect(serialized).toContain("anthropic") + }), + ) + + it.live("returns remote auth defects as structured client errors", () => + Effect.gen(function* () { + const configError = new ConfigErrorV1.RemoteAuthError({ + url: "https://example.com", + remote: "https://config.example.com/opencode.json", + }) + + yield* HttpRouter.add("GET", "/remote-auth-error", Effect.die(configError)).pipe( + Layer.provide(errorLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClientRequest.get("/remote-auth-error").pipe(HttpClient.execute) + const body = yield* response.json + + expect(response.status).toBe(400) + expect(body).toEqual(configError.toObject()) + }), + ) + + it.live("does not map storage not-found defects to 404", () => + Effect.gen(function* () { + yield* HttpRouter.add( + "GET", + "/missing", + Effect.die(new NotFoundError({ message: "Resource not found: secret" })), + ).pipe(Layer.provide(errorLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClientRequest.get("/missing").pipe(HttpClient.execute) + const body = yield* response.json + + expect(response.status).toBe(500) + expectUnknownErrorBody(body) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b6be2ab6ad7fc5f9b37a1cf8d9928ebf0076c8e --- /dev/null +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer, Queue, Schema, Stream } from "effect" +import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const EventData = Schema.Struct({ + id: Schema.optional(Schema.String), + type: Schema.String, + properties: Schema.Record(Schema.String, Schema.Any), +}) + +const readEvent = (reader: Queue.Dequeue) => + Effect.gen(function* () { + const value = yield* Queue.take(reader).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.fail(new Error("timed out waiting for event")), + }), + ) + return Schema.decodeUnknownSync(EventData)(JSON.parse(new TextDecoder().decode(value).replace(/^data: /, ""))) + }) + +const openEventStream = (directory: string) => + Effect.gen(function* () { + const response = yield* requestInDirectory(EventPaths.event, directory) + const reader = yield* Queue.unbounded() + yield* response.stream.pipe( + Stream.runForEach((value) => Queue.offer(reader, value)), + Effect.forkScoped, + ) + return { response, reader } + }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +const it = testEffect(httpApiLayer) + +describe("event HttpApi", () => { + it.instance( + "serves event stream", + () => + Effect.gen(function* () { + const { directory } = yield* TestInstance + const { response, reader } = yield* openEventStream(directory) + + expect(response.status).toBe(200) + expect(response.headers["content-type"]).toContain("text/event-stream") + expect(response.headers["cache-control"]).toBe("no-cache, no-transform") + expect(response.headers["x-accel-buffering"]).toBe("no") + expect(response.headers["x-content-type-options"]).toBe("nosniff") + expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "keeps the event stream open after the initial event", + () => + Effect.gen(function* () { + const { directory } = yield* TestInstance + const { reader } = yield* openEventStream(directory) + expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} }) + + // If no second event arrives within 250ms, the stream is still open. + const status = yield* Queue.take(reader).pipe( + Effect.as("event" as const), + Effect.timeoutOrElse({ duration: "250 millis", orElse: () => Effect.succeed("open" as const) }), + ) + expect(status).toBe("open") + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "delivers instance events after the initial event", + () => + Effect.gen(function* () { + const { directory } = yield* TestInstance + const { reader } = yield* openEventStream(directory) + expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} }) + + const created = yield* requestInDirectory("/session", directory, { method: "POST" }) + expect(created.status).toBe(200) + expect(yield* readEvent(reader)).toMatchObject({ type: "session.created" }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) +}) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..171716435697e036351e7a134cac425994561efe --- /dev/null +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Deferred, Effect, Fiber, Layer } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { eq } from "drizzle-orm" +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { Session } from "@/session/session" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Database } from "@opencode-ai/core/database/database" +import { AccountV2 } from "@opencode-ai/core/account" +import { AccountTable } from "@opencode-ai/core/account/sql" +import { Worktree } from "../../src/worktree" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) +const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance + +function request(path: string, directory: string, init: RequestInit = {}) { + return requestInDirectory(path, directory, init) +} + +function createSession(input?: Session.CreateInput) { + return Session.use.create(input) +} + +function json(response: HttpClientResponse.HttpClientResponse) { + return response.json.pipe(Effect.map((value) => value as T)) +} + +function waitReady(input: { directory?: string; name?: string }) { + return Effect.gen(function* () { + const ready = yield* Deferred.make() + const on = (event: GlobalEvent) => { + if (event.payload.type !== Worktree.Event.Ready.type) return + if (input.directory && event.directory !== input.directory) return + if (input.name && event.payload.properties.name !== input.name) return + Deferred.doneUnsafe(ready, Effect.void) + } + + GlobalBus.on("event", on) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) + + return yield* Deferred.await(ready).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")), + }), + ) + }) +} + +function insertAccount() { + return Effect.acquireRelease( + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(AccountTable) + .values({ + id: AccountV2.ID.make("account-test"), + email: "test@example.com", + url: "https://console.example.com", + access_token: AccountV2.AccessToken.make("access"), + refresh_token: AccountV2.RefreshToken.make("refresh"), + time_created: Date.now(), + time_updated: Date.now(), + }) + .run() + .pipe(Effect.orDie) + return "account-test" + }), + (id) => + Database.Service.use(({ db }) => + db + .delete(AccountTable) + .where(eq(AccountTable.id, AccountV2.ID.make(id))) + .run() + .pipe(Effect.orDie), + ), + ) +} + +function setSessionUpdated(session: Session.Info, updated: number) { + return Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ time_updated: updated }) + .where(eq(SessionTable.id, session.id)) + .run() + .pipe(Effect.orDie) + }) +} + +function withCreatedWorktree( + directory: string, + use: (info: Worktree.Info) => Effect.Effect, +) { + const name = "api-test" + const headers = { "content-type": "application/json" } + return Effect.acquireUseRelease( + Effect.gen(function* () { + const ready = yield* waitReady({ name }).pipe(Effect.forkScoped) + const created = yield* request(ExperimentalPaths.worktree, directory, { + method: "POST", + headers, + body: JSON.stringify({ name }), + }) + + expect(created.status).toBe(200) + const info = yield* json(created) + expect(info).toMatchObject({ name, branch: "opencode/api-test" }) + yield* Fiber.join(ready) + return info + }), + use, + (info) => + Effect.gen(function* () { + const removed = yield* request(ExperimentalPaths.worktree, directory, { + method: "DELETE", + headers, + body: JSON.stringify({ directory: info.directory }), + }) + if (removed.status !== 200) return yield* Effect.fail(new Error(`failed to remove worktree: ${removed.status}`)) + const ok = yield* json(removed) + if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${info.directory}`)) + }), + ) +} + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("experimental HttpApi", () => { + it.instance( + "serves read-only experimental endpoints through the default server app", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const directory = tmp.directory + const [consoleState, consoleOrgs, toolList, toolIDs, worktrees, resources] = yield* Effect.all( + [ + request(ExperimentalPaths.console, directory), + request(ExperimentalPaths.consoleOrgs, directory), + request(`${ExperimentalPaths.tool}?provider=opencode&model=gpt-5`, directory), + request(ExperimentalPaths.toolIDs, directory), + request(ExperimentalPaths.worktree, directory), + request(ExperimentalPaths.resource, directory), + ], + { concurrency: "unbounded" }, + ) + + expect(consoleState.status).toBe(200) + expect(yield* json(consoleState)).toEqual({ + consoleManagedProviders: [], + switchableOrgCount: 0, + }) + + expect(consoleOrgs.status).toBe(200) + expect(yield* json(consoleOrgs)).toEqual({ orgs: [] }) + + expect(toolList.status).toBe(200) + expect(yield* json(toolList)).toContainEqual( + expect.objectContaining({ + id: "bash", + description: expect.any(String), + parameters: expect.any(Object), + }), + ) + + expect(toolIDs.status).toBe(200) + expect(yield* json(toolIDs)).toContain("bash") + + expect(worktrees.status).toBe(200) + expect(yield* json(worktrees)).toEqual([]) + + expect(resources.status).toBe(200) + expect(yield* json(resources)).toEqual({}) + }), + { + config: { + formatter: false, + lsp: false, + mcp: { + demo: { + type: "local", + command: ["echo", "demo"], + enabled: false, + }, + }, + }, + }, + ) + + it.instance("returns declared worktree errors", () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const response = yield* request(ExperimentalPaths.worktree, tmp.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(400) + expect(yield* json(response)).toEqual({ + name: "WorktreeNotGitError", + data: { message: "Worktrees are only supported for git projects" }, + }) + }), + ) + + it.instance( + "serves Console org switch through the default server app", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const accountID = yield* insertAccount() + const switched = yield* request(ExperimentalPaths.consoleSwitch, tmp.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountID, orgID: "org-test" }), + }) + + expect(switched.status).toBe(200) + expect(yield* json(switched)).toBe(true) + }), + { config: { formatter: false, lsp: false } }, + ) + + it.instance( + "serves global session list through the default server app", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const first = yield* createSession({ title: "page-one" }) + const second = yield* createSession({ title: "page-two" }) + yield* setSessionUpdated(first, 1) + yield* setSessionUpdated(second, 2) + + const page = yield* request( + `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.directory, limit: "1" })}`, + tmp.directory, + ) + expect(page.status).toBe(200) + expect(page.headers["x-next-cursor"]).toBeTruthy() + + const body = yield* json(page) + expect(body.map((session) => session.id)).toEqual([second.id]) + expect(body[0].project?.id).toBe(second.projectID) + + const next = yield* request( + `${ExperimentalPaths.session}?${new URLSearchParams({ + directory: tmp.directory, + limit: "10", + cursor: body[0].time.updated.toString(), + })}`, + tmp.directory, + ) + expect(next.status).toBe(200) + expect((yield* json(next)).map((session) => session.id)).toContain(first.id) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + testWorktreeMutations( + "serves worktree mutations through the default server app", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + yield* withCreatedWorktree(tmp.directory, (info) => + Effect.gen(function* () { + const listed = yield* request(ExperimentalPaths.worktree, tmp.directory) + expect(listed.status).toBe(200) + expect(yield* json(listed)).toContain(info.directory) + + const reset = yield* request(ExperimentalPaths.worktreeReset, tmp.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ directory: info.directory }), + }) + + expect(reset.status).toBe(200) + expect(yield* json(reset)).toBe(true) + }), + ) + + const afterRemove = yield* request(ExperimentalPaths.worktree, tmp.directory) + expect(afterRemove.status).toBe(200) + expect(yield* json(afterRemove)).toEqual([]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) +}) diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed882ade465254ea08d4947c7bab9890fce43ee0 --- /dev/null +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Context, Effect } from "effect" +import path from "path" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { pollWithTimeout } from "../lib/effect" + +const context = Context.empty() as Context.Context + +function request(route: string, directory: string, query?: Record) { + const url = new URL(`http://localhost${route}`) + for (const [key, value] of Object.entries(query ?? {})) { + url.searchParams.set(key, value) + } + return HttpApiApp.webHandler().handler( + new Request(url, { + headers: { + "x-opencode-directory": directory, + }, + }), + context, + ) +} + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("file HttpApi", () => { + test("serves read endpoints", async () => { + await using tmp = await tmpdir({ git: true }) + await Bun.write(path.join(tmp.path, "hello.txt"), "hello") + + const [list, content, status] = await Promise.all([ + request(FilePaths.list, tmp.path, { path: "." }), + request(FilePaths.content, tmp.path, { path: "hello.txt" }), + request(FilePaths.status, tmp.path), + ]) + + expect(list.status).toBe(200) + expect(await list.json()).toContainEqual( + expect.objectContaining({ name: "hello.txt", path: "hello.txt", type: "file" }), + ) + + expect(content.status).toBe(200) + expect(await content.json()).toMatchObject({ type: "text", content: "hello" }) + + expect(status.status).toBe(200) + expect(await status.json()).toEqual([]) + }) + + test("serves search endpoints", async () => { + await using tmp = await tmpdir({ git: true }) + await Bun.write(path.join(tmp.path, "hello.txt"), "needle") + + const [text, symbols] = await Promise.all([ + request(FilePaths.findText, tmp.path, { pattern: "needle" }), + request(FilePaths.findSymbol, tmp.path, { query: "hello" }), + ]) + const files = await Effect.runPromise( + pollWithTimeout( + Effect.promise(async () => { + const response = await request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }) + const body = await response.json() + return body.includes("hello.txt") ? { response, body } : undefined + }), + "file search index was not ready", + ), + ) + + expect(text.status).toBe(200) + expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 })) + + expect(files.response.status).toBe(200) + expect(files.body).toContain("hello.txt") + + expect(symbols.status).toBe(200) + expect(await symbols.json()).toEqual([]) + }) +}) diff --git a/packages/opencode/test/server/httpapi-global.test.ts b/packages/opencode/test/server/httpapi-global.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..55bdcff4f59fbac80b0df6e4191c2e97a45d8633 --- /dev/null +++ b/packages/opencode/test/server/httpapi-global.test.ts @@ -0,0 +1,90 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Context, Effect, Layer, Option } from "effect" +import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Auth } from "../../src/auth" +import { Config } from "../../src/config/config" +import { Installation } from "../../src/installation" +import { MoveSession } from "@opencode-ai/core/control-plane/move-session" +import { ServerAuth } from "../../src/server/auth" +import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api" +import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" +import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control" +import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane" +import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global" +import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error" +import { testEffect } from "../lib/effect" + +const apiLayer = HttpRouter.serve( + HttpApiBuilder.layer(RootHttpApi).pipe( + Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]), + Layer.provide([authorizationLayer, schemaErrorLayer]), + // Raw HttpApi routes expose an opaque handler context at the request boundary. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context)), + ), + { disableListenLog: true, disableLogger: true }, +).pipe( + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provide(Layer.mock(Auth.Service)({})), + Layer.provide(Layer.mock(Config.Service)({})), + Layer.provide(Layer.mock(MoveSession.Service)({})), + Layer.provide( + Layer.mock(Installation.Service)({ + method: () => Effect.succeed("npm"), + latest: () => Effect.succeed("9.9.9"), + upgrade: () => Effect.void, + }), + ), + Layer.provide(ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" })), +) +const it = testEffect(apiLayer) + +describe("global HttpApi", () => { + it.live("upgrades to the requested version", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe( + HttpClientRequest.bodyJsonUnsafe({ target: "9.9.9" }), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ success: true, version: "9.9.9" }) + }), + ) + + it.live("rejects invalid upgrade payloads", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe( + HttpClientRequest.bodyJsonUnsafe({ target: 1 }), + HttpClient.execute, + ) + + expect(response.status).toBe(400) + }), + ) + + it.live("rejects invalid upgrade target versions", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe( + HttpClientRequest.bodyJsonUnsafe({ target: "latest" }), + HttpClient.execute, + ) + + expect(response.status).toBe(400) + }), + ) + + it.live("rejects unsupported upgrade content types", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe( + HttpClientRequest.setBody(HttpBody.text('{"target":"1.0.0"}', "text/plain")), + HttpClient.execute, + ) + + expect(response.status).toBe(415) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..845324b34aabeac45c7eabe467f150320136a193 --- /dev/null +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -0,0 +1,352 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Effect, Fiber, Layer, Schema } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import * as Socket from "effect/unstable/socket/Socket" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { registerAdapter } from "../../src/control-plane/adapters" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" +import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { Project } from "../../src/project/project" +import { Session } from "../../src/session/session" +import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" +import { + InstanceContextMiddleware, + instanceContextLayer, +} from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + workspaceRoutingLayer, +} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { withFixedWorkspaceID } from "../fixture/flag" +import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace" +import { waitGlobalBusEvent } from "./global-bus" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await disposeAllInstances() + await resetDatabase() + }), + ) + }), +) + +const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true }) + +const it = testEffect(Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, workspaceLayer)) + +const instanceContextTestLayer = Layer.mergeAll( + instanceContextLayer, + workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), +) + +const localAdapter = (directory: string): WorkspaceAdapter => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => + Effect.acquireRelease( + Effect.gen(function* () { + registerAdapter(input.projectID, input.type, localAdapter(input.directory)) + const workspace = yield* Workspace.Service + return yield* workspace.create({ + type: input.type, + branch: null, + extra: null, + projectID: input.projectID, + }) + }), + (info) => Workspace.use.remove(info.id).pipe(Effect.ignore), + ) + +const probeInstanceContext = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return { + directory: instance?.directory, + worktree: instance?.worktree, + projectID: instance?.project.id, + workspaceID, + } +}) + +const ProbeResult = Schema.Struct({ + directory: Schema.optional(Schema.String), + worktree: Schema.optional(Schema.String), + projectID: Schema.optional(Schema.String), + workspaceID: Schema.optional(Schema.String), +}) + +const ProbeApi = HttpApi.make("instance-context-probe").add( + HttpApiGroup.make("probe") + .add( + HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }), + HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }), + HttpApiEndpoint.post("dispose", "/dispose-probe", { + query: WorkspaceRoutingQuery, + success: Schema.Boolean, + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware), +) + +const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) => + handlers + .handle("get", () => probeInstanceContext) + .handle("session", () => probeInstanceContext) + .handle( + "dispose", + Effect.fn("InstanceContextProbe.dispose")(function* () { + const instance = yield* InstanceRef + if (!instance) return false + yield* markInstanceForDisposal(instance) + return true + }), + ), +) + +const probeRoutes = HttpApiBuilder.layer(ProbeApi).pipe( + Layer.provide(probeHandlers), + Layer.provide(instanceContextTestLayer), + Layer.provide(Layer.mock(Session.Service)({})), +) + +const serveProbe = () => probeRoutes.pipe(HttpRouter.serve, Layer.build) + +const waitDisposedEvent = waitGlobalBusEvent({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed", +}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace }))) + +const serveDisposeProbe = () => + HttpRouter.serve(probeRoutes, { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ) + +describe("HttpApi instance context middleware", () => { + it.live("provides instance context from the routed directory", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + yield* serveProbe() + + const response = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(dir)}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ + directory: dir, + worktree: dir, + projectID: project.project.id, + workspaceID: null, + }) + }), + ) + + it.live("persists the routed project while loading instance context", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.Service + yield* serveProbe() + + const response = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(dir)}`) + + expect(response.status).toBe(200) + const saved = (yield* project.list()).find((item) => item.worktree === dir) + expect(saved).toBeDefined() + expect(saved?.id).not.toBe("global") + }), + ) + + it.live("falls back to the raw directory when URI decoding fails", () => + Effect.gen(function* () { + yield* serveProbe() + + const response = yield* HttpClient.get("/probe?directory=%25E0%25A4%25A") + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: path.join(process.cwd(), "%E0%A4%A"), + }) + }), + ) + + it.live("provides selected workspace id on control-plane routes", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-workspace-ref", + directory: workspaceDir, + }) + yield* serveProbe() + + const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe( + HttpClientRequest.setHeader("x-opencode-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: dir, + workspaceID: workspace.id, + }) + }), + ) + + it.live("uses workspace routing output instead of raw directory hints", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-routing-output", + directory: workspaceDir, + }) + yield* serveProbe() + + const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe( + HttpClientRequest.setHeader("x-opencode-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: workspaceDir, + workspaceID: workspace.id, + }) + }), + ) + + it.live("uses configured workspace id instead of routing to the requested workspace", () => + Effect.gen(function* () { + const fixedWorkspaceID = WorkspaceV2.ID.ascending() + yield* withFixedWorkspaceID(fixedWorkspaceID) + + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-fixed-workspace-ref", + directory: workspaceDir, + }) + yield* serveProbe() + + const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe( + HttpClientRequest.setHeader("x-opencode-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: dir, + workspaceID: fixedWorkspaceID, + }) + }), + ) + + it.live("falls through to local instead of MissingWorkspace when configured workspace id is set", () => + Effect.gen(function* () { + const fixedWorkspaceID = WorkspaceV2.ID.ascending() + yield* withFixedWorkspaceID(fixedWorkspaceID) + + const dir = yield* tmpdirScoped({ git: true }) + yield* Project.use.fromDirectory(dir) + yield* serveProbe() + + // Reference a workspace id that is not registered locally. Without the + // configured env override, this would short-circuit to a 500 + // MissingWorkspace response. With the env set, planRequest must skip the + // MissingWorkspace branch and fall through to Local with the configured + // workspace id. + const unknownWorkspaceID = WorkspaceV2.ID.ascending() + const response = yield* HttpClientRequest.get(`/probe?workspace=${unknownWorkspaceID}`).pipe( + HttpClientRequest.setHeader("x-opencode-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: dir, + workspaceID: fixedWorkspaceID, + }) + }), + ) + + it.live("keeps configured workspace id on control-plane routes without remote routing", () => + Effect.gen(function* () { + const fixedWorkspaceID = WorkspaceV2.ID.ascending() + yield* withFixedWorkspaceID(fixedWorkspaceID) + + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-fixed-workspace-control-plane", + directory: workspaceDir, + }) + // /session is matched by isLocalWorkspaceRoute, so shouldStayOnControlPlane + // is true. Combined with the env override, the route must stay Local with + // the configured workspace id (not divert to the requested workspace's + // local directory). + yield* serveProbe() + + const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe( + HttpClientRequest.setHeader("x-opencode-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: dir, + workspaceID: fixedWorkspaceID, + }) + }), + ) + + it.live("preserves selected workspace id on instance disposal events", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-dispose-event", + directory: workspaceDir, + }) + yield* serveDisposeProbe() + const disposed = yield* waitDisposedEvent.pipe(Effect.forkScoped({ startImmediately: true })) + + const response = yield* HttpClientRequest.post(`/dispose-probe?workspace=${workspace.id}`).pipe( + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toBe(true) + expect(yield* Fiber.join(disposed)).toEqual({ directory: workspaceDir, workspace: workspace.id }) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-instance-route-auth.test.ts b/packages/opencode/test/server/httpapi-instance-route-auth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9933a28bdc8e08bcc43255fe3f92e2c1220269fa --- /dev/null +++ b/packages/opencode/test/server/httpapi-instance-route-auth.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { ConfigProvider, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { ServerAuth } from "../../src/server/auth" +import { PtyID } from "@opencode-ai/core/pty/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +function app(input: { password?: string; username?: string }) { + const handler = HttpRouter.toWebHandler( + HttpApiApp.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_SERVER_PASSWORD: input.password, + OPENCODE_SERVER_USERNAME: input.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + + return { + fetch: (request: Request) => handler(request, HttpApiApp.context), + request(input: string | URL | Request, init?: RequestInit) { + return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) + }, + } +} + +function basic(username: string, password: string) { + return ServerAuth.header({ username, password }) ?? "" +} + +async function cancelBody(response: Response) { + await response.body?.cancel().catch(() => {}) +} + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("HttpApi instance route authorization", () => { + test("requires configured auth before opening the instance event stream", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const server = app({ password: "secret" }) + const headers = { "x-opencode-directory": tmp.path } + + const missing = await server.request(EventPaths.event, { headers }) + await cancelBody(missing) + expect(missing.status).toBe(401) + + const authed = await server.request(EventPaths.event, { + headers: { ...headers, authorization: basic("opencode", "secret") }, + }) + await cancelBody(authed) + expect(authed.status).toBe(200) + }) + + test("requires configured auth before resolving the PTY websocket route", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const server = app({ password: "secret" }) + const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending()) + const headers = { "x-opencode-directory": tmp.path } + + const missing = await server.request(route, { headers }) + await cancelBody(missing) + expect(missing.status).toBe(401) + + const authed = await server.request(route, { + headers: { ...headers, authorization: basic("opencode", "secret") }, + }) + await cancelBody(authed) + expect(authed.status).toBe(404) + }) +}) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5bbd6d7cc0d86cb274c7a03f2fb48e1711f11903 --- /dev/null +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -0,0 +1,265 @@ +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Config, Context, Effect, FileSystem, Layer, Path } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { ProjectV2 } from "@opencode-ai/core/project" +import { QuestionID } from "../../src/question/schema" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { HEADER as FenceHeader } from "../../src/server/shared/fence" +import { resetDatabase } from "../fixture/db" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +// Flip the experimental workspaces flag so EventV2.run actually writes to +// EventSequenceTable (the source of truth the fence middleware reads). Reset +// the database around the test so per-instance state does not leak between +// runs. resetDatabase() already calls disposeAllInstances(), so we don't +// repeat it. +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await resetDatabase() + }), + ) + }), +) + +// Mount the production HttpApi route tree on a real Node HTTP server bound to +// 127.0.0.1:0 and a fetch-based HttpClient that prepends the server URL. This +// keeps the test wired directly through the same route layer production uses. +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { disableListenLog: true, disableLogger: true }, +) + +const httpApiServerLayer = servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), +) + +const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer)) +const handlerContext = Context.empty() as Context.Context + +const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir) + +describe("instance HttpApi", () => { + it.live("serves the OpenAPI document", () => + Effect.gen(function* () { + const response = yield* HttpClient.get("/doc") + + expect(response.status).toBe(200) + expect(response.headers["content-type"]).toContain("application/json") + expect(yield* response.json).toMatchObject({ + openapi: expect.any(String), + info: expect.any(Object), + paths: expect.objectContaining({ + "/global/health": expect.any(Object), + "/session": expect.any(Object), + }), + }) + }), + ) + + it.live("emits a sync fence header for fixed-workspace mutations", () => + Effect.gen(function* () { + const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID + Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending() + yield* Effect.addFinalizer(() => + Effect.sync(() => { + Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID + }), + ) + + const dir = yield* tmpdirScoped({ git: true }) + const response = yield* HttpClientRequest.post(SessionPaths.create).pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ title: "fenced" }), + Effect.flatMap(HttpClient.execute), + ) + + expect(response.status).toBe(200) + expect(JSON.parse(response.headers[FenceHeader] ?? "{}")).not.toEqual({}) + }), + ) + + it.live("does not emit sync fence headers for fixed-workspace reads or no-op mutations", () => + Effect.gen(function* () { + const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID + Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending() + yield* Effect.addFinalizer(() => + Effect.sync(() => { + Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID + }), + ) + + const dir = yield* tmpdirScoped({ git: true }) + const read = yield* HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute) + const log = yield* HttpClientRequest.post(ControlPaths.log).pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ service: "fence-test", level: "info", message: "noop" }), + Effect.flatMap(HttpClient.execute), + ) + + expect(read.status).toBe(200) + expect(read.headers[FenceHeader]).toBeUndefined() + expect(log.status).toBe(200) + expect(log.headers[FenceHeader]).toBeUndefined() + }), + ) + + it.live("rejects malformed permission and question request ids", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const request = (path: string, init?: RequestInit) => + Effect.promise(() => + HttpApiApp.webHandler().handler( + new Request(`http://localhost${path}`, { + ...init, + headers: { "x-opencode-directory": dir, "content-type": "application/json", ...init?.headers }, + }), + handlerContext, + ), + ) + const [permission, questionReply, questionReject] = yield* Effect.all( + [ + request("/permission/invalid-permission-id/reply", { + method: "POST", + body: JSON.stringify({ reply: "once" }), + }), + request("/question/invalid-question-id/reply", { + method: "POST", + body: JSON.stringify({ answers: [["Yes"]] }), + }), + request("/question/invalid-question-id/reject", { method: "POST" }), + ], + { concurrency: "unbounded" }, + ) + + expect(permission.status).toBe(400) + expect(questionReply.status).toBe(400) + expect(questionReject.status).toBe(400) + }), + ) + + it.live("returns typed not found bodies for missing permission and question requests", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const request = (path: string, init?: RequestInit) => + Effect.promise(() => + HttpApiApp.webHandler().handler( + new Request(`http://localhost${path}`, { + ...init, + headers: { "x-opencode-directory": dir, "content-type": "application/json", ...init?.headers }, + }), + handlerContext, + ), + ) + const permissionID = PermissionV1.ID.ascending() + const questionReplyID = QuestionID.ascending() + const questionRejectID = QuestionID.ascending() + const [permission, questionReply, questionReject] = yield* Effect.all( + [ + request(`/permission/${permissionID}/reply`, { + method: "POST", + body: JSON.stringify({ reply: "once" }), + }), + request(`/question/${questionReplyID}/reply`, { + method: "POST", + body: JSON.stringify({ answers: [["Yes"]] }), + }), + request(`/question/${questionRejectID}/reject`, { method: "POST" }), + ], + { concurrency: "unbounded" }, + ) + + expect(permission.status).toBe(404) + expect(yield* Effect.promise(() => permission.json())).toEqual({ + _tag: "PermissionNotFoundError", + requestID: permissionID, + message: `Permission request not found: ${permissionID}`, + }) + expect(questionReply.status).toBe(404) + expect(yield* Effect.promise(() => questionReply.json())).toEqual({ + _tag: "QuestionNotFoundError", + requestID: questionReplyID, + message: `Question request not found: ${questionReplyID}`, + }) + expect(questionReject.status).toBe(404) + expect(yield* Effect.promise(() => questionReject.json())).toEqual({ + _tag: "QuestionNotFoundError", + requestID: questionRejectID, + message: `Question request not found: ${questionRejectID}`, + }) + }), + ) + + it.live("returns typed not found bodies for missing projects", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const projectID = ProjectV2.ID.make("project_missing") + const response = yield* Effect.promise(() => + HttpApiApp.webHandler().handler( + new Request(`http://localhost/project/${projectID}`, { + method: "PATCH", + headers: { "x-opencode-directory": dir, "content-type": "application/json" }, + body: JSON.stringify({ name: "Missing" }), + }), + handlerContext, + ), + ) + + expect(response.status).toBe(404) + expect(yield* Effect.promise(() => response.json())).toEqual({ + _tag: "ProjectNotFoundError", + projectID, + message: `Project not found: ${projectID}`, + }) + }), + ) + + it.live("serves path and VCS read endpoints", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + yield* fs.writeFileString(path.join(dir, "changed.txt"), "hello") + + const [paths, vcs, diff] = yield* Effect.all( + [ + HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute), + HttpClientRequest.get(InstancePaths.vcs).pipe(directoryHeader(dir), HttpClient.execute), + HttpClientRequest.get(InstancePaths.vcsDiff).pipe( + HttpClientRequest.setUrlParam("mode", "git"), + directoryHeader(dir), + HttpClient.execute, + ), + ], + { concurrency: "unbounded" }, + ) + + expect(paths.status).toBe(200) + expect(yield* paths.json).toMatchObject({ directory: dir, worktree: dir }) + + expect(vcs.status).toBe(200) + expect(yield* vcs.json).toMatchObject({ branch: expect.any(String) }) + + expect(diff.status).toBe(200) + expect(yield* diff.json).toContainEqual( + expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }), + ) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-layer.ts b/packages/opencode/test/server/httpapi-layer.ts new file mode 100644 index 0000000000000000000000000000000000000000..a780391ae9e57a12a29c614c293725b30d148d2e --- /dev/null +++ b/packages/opencode/test/server/httpapi-layer.ts @@ -0,0 +1,33 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Config, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" + +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { + disableListenLog: true, + disableLogger: true, + }, +) + +export const httpApiLayer = servedRoutes.pipe( + Layer.provide(layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), +) + +export function request(path: string, init?: RequestInit) { + const url = new URL(path, "http://localhost") + return HttpClientRequest.fromWeb(new Request(url, init)).pipe( + HttpClientRequest.setUrl(url.pathname), + HttpClient.execute, + ) +} + +export function requestInDirectory(path: string, directory: string, init: RequestInit = {}) { + const headers = new Headers(init.headers) + headers.set("x-opencode-directory", directory) + return request(path, { ...init, headers }) +} diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..585c59cb43176241d24939eae5372b0748f388e7 --- /dev/null +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -0,0 +1,465 @@ +import { afterEach, describe, expect, test } from "bun:test" +import net from "node:net" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Server } from "../../src/server/server" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" +import { withTimeout } from "../../src/util/timeout" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, + envPassword: process.env.OPENCODE_SERVER_PASSWORD, + envUsername: process.env.OPENCODE_SERVER_USERNAME, +} +const auth = { username: "opencode", password: "listen-secret" } +const testPty = process.platform === "win32" ? test.skip : test + +afterEach(async () => { + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD + else process.env.OPENCODE_SERVER_PASSWORD = original.envPassword + if (original.envUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME + else process.env.OPENCODE_SERVER_USERNAME = original.envUsername + await disposeAllInstances() + await resetDatabase() +}) + +async function startListener() { + Flag.OPENCODE_SERVER_PASSWORD = auth.password + Flag.OPENCODE_SERVER_USERNAME = auth.username + process.env.OPENCODE_SERVER_PASSWORD = auth.password + process.env.OPENCODE_SERVER_USERNAME = auth.username + return Server.listen({ hostname: "127.0.0.1", port: 0 }) +} + +async function startNoAuthListener() { + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = auth.username + delete process.env.OPENCODE_SERVER_PASSWORD + process.env.OPENCODE_SERVER_USERNAME = auth.username + return Server.listen({ hostname: "127.0.0.1", port: 0 }) +} + +function authorization() { + return `Basic ${btoa(`${auth.username}:${auth.password}`)}` +} + +function socketURL(listener: Awaited>, id: string, dir: string, ticket?: string) { + const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url) + url.protocol = "ws:" + url.searchParams.set("directory", dir) + url.searchParams.set("cursor", "-1") + if (ticket) url.searchParams.set("ticket", ticket) + return url +} + +async function requestTicket( + listener: Awaited>, + id: string, + dir: string, + options?: { ticketHeader?: boolean; origin?: string }, +) { + const response = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", id), listener.url), { + method: "POST", + headers: { + authorization: authorization(), + "x-opencode-directory": dir, + ...(options?.ticketHeader === false ? {} : { "x-opencode-ticket": "1" }), + ...(options?.origin ? { origin: options.origin } : {}), + }, + }) + + return response +} + +async function connectTicket(listener: Awaited>, id: string, dir: string) { + const response = await requestTicket(listener, id, dir) + expect(response.status).toBe(200) + return (await response.json()) as { ticket: string; expires_in: number } +} + +async function createCat(listener: Awaited>, dir: string) { + const response = await fetch(new URL(PtyPaths.create, listener.url), { + method: "POST", + headers: { + authorization: authorization(), + "x-opencode-directory": dir, + "content-type": "application/json", + }, + body: JSON.stringify({ command: "/bin/cat", title: "listen-smoke" }), + }) + expect(response.status).toBe(200) + return (await response.json()) as { id: string } +} + +async function openSocket(url: URL) { + const ws = new WebSocket(url) + ws.binaryType = "arraybuffer" + await withTimeout( + new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }) + ws.addEventListener("error", () => reject(new Error("websocket failed before open")), { once: true }) + }), + 5_000, + "timed out waiting for websocket open", + ) + return ws +} + +async function expectSocketRejected(url: URL, init?: { headers?: Record }) { + // Bun's WebSocket accepts an init object with headers; standard DOM types don't reflect that. + const Ctor = WebSocket as unknown as new (url: URL, init?: { headers?: Record }) => WebSocket + const ws = new Ctor(url, init) + await withTimeout( + new Promise((resolve, reject) => { + ws.addEventListener( + "open", + () => { + ws.close(1000) + reject(new Error("websocket opened")) + }, + { once: true }, + ) + ws.addEventListener("error", () => resolve(), { once: true }) + ws.addEventListener("close", () => resolve(), { once: true }) + }), + 5_000, + "timed out waiting for websocket rejection", + ) +} + +function stop(listener: Awaited>, label: string) { + return withTimeout(listener.stop(true), 10_000, label) +} + +function waitForMessage(ws: WebSocket, predicate: (message: string) => boolean) { + const decoder = new TextDecoder() + let onMessage: ((event: MessageEvent) => void) | undefined + return withTimeout( + new Promise((resolve) => { + onMessage = (event: MessageEvent) => { + const message = typeof event.data === "string" ? event.data : decoder.decode(event.data as ArrayBuffer) + if (!predicate(message)) return + resolve(message) + } + ws.addEventListener("message", onMessage) + }), + 5_000, + "timed out waiting for websocket message", + ).finally(() => { + if (onMessage) ws.removeEventListener("message", onMessage) + }) +} + +async function openPtySocket(listener: Awaited>, dir: string) { + const info = await createCat(listener, dir) + const ticket = await connectTicket(listener, info.id, dir) + const ws = await openSocket(socketURL(listener, info.id, dir, ticket.ticket)) + return { + ws, + closed: new Promise((resolve) => ws.addEventListener("close", () => resolve(), { once: true })), + } +} + +describe("HttpApi Server.listen", () => { + testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const listener = await startListener() + let stopped = false + try { + const response = await fetch(new URL(PtyPaths.shells, listener.url), { + headers: { authorization: authorization(), "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: expect.any(String), + name: expect.any(String), + acceptable: expect.any(Boolean), + }), + ]), + ) + + const info = await createCat(listener, tmp.path) + const ticket = await connectTicket(listener, info.id, tmp.path) + expect(ticket.expires_in).toBeGreaterThan(0) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) + const closed = new Promise((resolve) => ws.addEventListener("close", () => resolve(), { once: true })) + + const message = waitForMessage(ws, (message) => message.includes("ping-listen")) + ws.send("ping-listen\n") + expect(await message).toContain("ping-listen") + + await stop(listener, "timed out waiting for listener.stop(true)") + stopped = true + await withTimeout(closed, 5_000, "timed out waiting for websocket close") + expect(ws.readyState).toBe(WebSocket.CLOSED) + + const restarted = await startListener() + try { + const nextInfo = await createCat(restarted, tmp.path) + const nextTicket = await connectTicket(restarted, nextInfo.id, tmp.path) + const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path, nextTicket.ticket)) + const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted")) + nextWs.send("ping-restarted\n") + expect(await nextMessage).toContain("ping-restarted") + nextWs.close(1000) + } finally { + await stop(restarted, "timed out waiting for restarted listener.stop(true)") + } + } finally { + if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined) + } + }) + + testPty("stop(true) is safe when called concurrently and repeatedly", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + let stopped = false + try { + const socket = await openPtySocket(listener, tmp.path) + + await withTimeout( + Promise.all([listener.stop(true), listener.stop(true)]).then(() => undefined), + 10_000, + "timed out waiting for concurrent listener.stop(true)", + ) + await withTimeout(socket.closed, 5_000, "timed out waiting for websocket close after concurrent stop") + await withTimeout(listener.stop(true), 5_000, "timed out waiting for repeated listener.stop(true)") + stopped = true + } finally { + if (!stopped) await stop(listener, "timed out cleaning up concurrent stop listener").catch(() => undefined) + } + }) + + testPty("stop(true) can force a graceful stop already in progress", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + let stopped = false + try { + const socket = await openPtySocket(listener, tmp.path) + + const graceful = listener.stop() + const forced = listener.stop(true) + await withTimeout( + Promise.all([graceful, forced]).then(() => undefined), + 10_000, + "timed out waiting for forced listener stop", + ) + await withTimeout(socket.closed, 5_000, "timed out waiting for websocket close after forced stop") + stopped = true + } finally { + if (!stopped) await stop(listener, "timed out cleaning up forced stop listener").catch(() => undefined) + } + }) + + testPty("graceful stop waits for an overlapping forced stop", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + let stopped = false + try { + const socket = await openPtySocket(listener, tmp.path) + const forced = listener.stop(true) + await withTimeout(listener.stop(), 10_000, "timed out waiting for graceful stop after forced stop") + stopped = true + await withTimeout(forced, 5_000, "timed out waiting for overlapping forced stop") + await withTimeout(socket.closed, 5_000, "timed out waiting for websocket close before graceful stop resolved") + } finally { + if (!stopped) await stop(listener, "timed out cleaning up overlapping stop listener").catch(() => undefined) + } + }) + + test("stop() gracefully closes an idle listener and is repeat-safe", async () => { + const listener = await startListener() + await withTimeout(listener.stop(), 10_000, "timed out waiting for graceful listener.stop()") + await withTimeout(listener.stop(), 5_000, "timed out waiting for repeated graceful listener.stop()") + await expect( + fetch(new URL(PtyPaths.shells, listener.url), { headers: { authorization: authorization() } }), + ).rejects.toThrow() + }) + + test("default in-process handler does not emit Effect HTTP response logs", async () => { + let output = "" + // oxlint-disable-next-line typescript-eslint/unbound-method -- restored in finally after temporarily capturing stderr. + const original = process.stderr.write + process.stderr.write = ((chunk) => { + output += String(chunk) + return true + }) as typeof process.stderr.write + try { + const response = await Server.Default().app.request("/status") + expect(response.status).toBe(200) + } finally { + process.stderr.write = original + } + + expect(output).not.toContain("Sent HTTP response") + }) + + test("plugin client requests reuse the listening server instance", async () => { + await using tmp = await tmpdir({ + init: async (directory) => { + const plugin = path.join(directory, "plugin.ts") + const initialized = path.join(directory, "initialized.txt") + const completed = path.join(directory, "completed.txt") + await Bun.write( + plugin, + [ + "export default async function plugin(input) {", + ` await Bun.write(${JSON.stringify(initialized)}, (await Bun.file(${JSON.stringify(initialized)}).text().catch(() => "")) + "initialized\\n")`, + " setTimeout(async () => {", + " await input.client.config.get()", + ` await Bun.write(${JSON.stringify(completed)}, "completed")`, + " }, 50)", + " return {}", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(directory, "opencode.json"), + JSON.stringify({ formatter: false, lsp: false, plugin: [pathToFileURL(plugin).href] }), + ) + return { initialized, completed } + }, + }) + const previous = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS + process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" + let listener: Awaited> | undefined + try { + listener = await startListener() + const response = await fetch(new URL("/config", listener.url), { + headers: { authorization: authorization(), "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(200) + await withTimeout( + (async () => { + while (!(await Bun.file(tmp.extra.completed).exists())) await Bun.sleep(10) + })(), + 5_000, + "timed out waiting for plugin client request", + ) + expect(await Bun.file(tmp.extra.initialized).text()).toBe("initialized\n") + } finally { + if (listener) await stop(listener, "timed out cleaning up plugin client listener").catch(() => undefined) + if (previous === undefined) delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS + else process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = previous + } + }) + + test("port 0 prefers 4096 when free", async () => { + if (!(await isPortFree(4096))) return + const listener = await startListener() + try { + expect(listener.port).toBe(4096) + } finally { + await stop(listener, "timed out cleaning up port-0 prefers-4096 listener") + } + }) + + test("port 0 falls back when 4096 is taken", async () => { + const blocker = await occupyPort(4096) + if (!blocker) return + try { + const listener = await startListener() + try { + expect(listener.port).not.toBe(4096) + expect(listener.port).toBeGreaterThan(0) + } finally { + await stop(listener, "timed out cleaning up port-0 fallback listener") + } + } finally { + await new Promise((resolve) => blocker.close(() => resolve())) + } + }) + + testPty("rejects unsafe PTY ticket mint and connect requests", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const info = await createCat(listener, tmp.path) + + expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403) + expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403) + + // Regression for #25698: minting without a directory uses the server cwd + // and cannot find a PTY registered in a project directory. + const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), { + method: "POST", + headers: { authorization: authorization(), "x-opencode-ticket": "1" }, + }) + expect(ambiguous.status).toBe(404) + + const directoryScoped = await fetch( + new URL( + `${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`, + listener.url, + ), + { + method: "POST", + headers: { authorization: authorization(), "x-opencode-ticket": "1" }, + }, + ) + expect(directoryScoped.status).toBe(200) + const mint = (await directoryScoped.json()) as { ticket: string } + const scopedWs = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket)) + scopedWs.close(1000) + + await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket")) + + const reusable = await connectTicket(listener, info.id, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, reusable.ticket)) + await expectSocketRejected(socketURL(listener, info.id, tmp.path, reusable.ticket)) + ws.close(1000) + + const other = await createCat(listener, tmp.path) + const scoped = await connectTicket(listener, info.id, tmp.path) + await expectSocketRejected(socketURL(listener, other.id, tmp.path, scoped.ticket)) + + const crossOrigin = await connectTicket(listener, info.id, tmp.path) + await expectSocketRejected(socketURL(listener, info.id, tmp.path, crossOrigin.ticket), { + headers: { origin: "https://evil.example" }, + }) + } finally { + await stop(listener, "timed out cleaning up rejected ticket listener").catch(() => undefined) + } + }) + + testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener() + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes("ping-no-auth")) + ws.send("ping-no-auth\n") + expect(await message).toContain("ping-no-auth") + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) +}) + +function isPortFree(port: number) { + return new Promise((resolve) => { + const probe = net.createServer() + probe.once("error", () => resolve(false)) + probe.once("listening", () => probe.close(() => resolve(true))) + probe.listen(port, "127.0.0.1") + }) +} + +function occupyPort(port: number) { + return new Promise((resolve) => { + const server = net.createServer() + server.once("error", () => resolve(undefined)) + server.listen(port, "127.0.0.1", () => resolve(server)) + }) +} diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3ca4ae6835b2d3c90e2a038040c0f2614c26b0b --- /dev/null +++ b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts @@ -0,0 +1,73 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { Session } from "@/session/session" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi" +import { McpApi, McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" +import { Authorization } from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { InstanceContextMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { + WorkspaceRouteContext, + WorkspaceRoutingMiddleware, +} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { testEffect } from "../lib/effect" + +const TestHttpApi = HttpApi.make("opencode-instance").addHttpApi(McpApi) +const fakeSession = Layer.mock(Session.Service)({}) +const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) => + Effect.succeed( + handlers + .handle("status", () => Effect.die("unexpected MCP status")) + .handle("add", () => Effect.die("unexpected MCP add")) + .handle("authStart", () => + Effect.succeed({ authorizationUrl: "https://auth.example/start", oauthState: "state-123" }), + ) + .handle("authCallback", () => Effect.die("unexpected MCP authCallback")) + .handle("authAuthenticate", () => Effect.die("unexpected MCP authAuthenticate")) + .handle("authRemove", () => Effect.die("unexpected MCP authRemove")) + .handle("connect", () => Effect.die("unexpected MCP connect")) + .handle("disconnect", () => Effect.die("unexpected MCP disconnect")), + ), +) + +const passthroughAuthorization = Layer.succeed( + Authorization, + Authorization.of((effect) => effect), +) + +const passthroughInstanceContext = Layer.succeed( + InstanceContextMiddleware, + InstanceContextMiddleware.of((effect) => effect), +) + +const testWorkspaceRouting = Layer.succeed( + WorkspaceRoutingMiddleware, + WorkspaceRoutingMiddleware.of((effect) => + effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory: process.cwd() }))), + ), +) + +const it = testEffect( + HttpRouter.serve( + HttpApiBuilder.layer(TestHttpApi).pipe( + Layer.provide(testMcpHandlers), + Layer.provide([passthroughAuthorization, passthroughInstanceContext, testWorkspaceRouting, fakeSession]), + ), + { disableListenLog: true, disableLogger: true }, + ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)), +) + +describe("mcp HttpApi OAuth", () => { + it.live("preserves oauth state when starting OAuth", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.post(McpPaths.auth.replace(":name", "demo")).pipe(HttpClient.execute) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ + authorizationUrl: "https://auth.example/start", + oauthState: "state-123", + }) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..42a4398ba720e4af86bbec152a8d4ba6b7a2878d --- /dev/null +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -0,0 +1,223 @@ +import { describe, expect } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" +import { Server } from "../../src/server/server" +import { resetDatabase } from "../fixture/db" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const context = Context.empty() as Context.Context +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()).pipe(Effect.ignore)) + }), +) +const it = testEffect(testStateLayer) + +function app() { + return Server.Default().app +} +type TestApp = ReturnType +type TestHandler = ReturnType + +const request = Effect.fnUntraced(function* ( + handler: TestHandler, + route: string, + directory: string, + init?: RequestInit, +) { + const headers = new Headers(init?.headers) + headers.set("x-opencode-directory", directory) + return yield* Effect.promise(() => + Promise.resolve( + handler.handler( + new Request(`http://localhost${route}`, { + ...init, + headers, + }), + context, + ), + ), + ) +}) + +const json = (response: Response) => Effect.promise(() => response.json() as Promise) + +const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: string; headers: HeadersInit }) { + const response = yield* Effect.promise(() => + Promise.resolve(input.app.request(input.path, { method: "POST", headers: input.headers })), + ) + return { + status: response.status, + body: yield* Effect.promise(() => response.text()), + } +}) + +describe("mcp HttpApi", () => { + it.instance( + "serves status endpoint", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const handler = HttpApiApp.webHandler() + const response = yield* request(handler, McpPaths.status, tmp.directory) + + expect(response.status).toBe(200) + expect(yield* json(response)).toEqual({ demo: { status: "disabled" } }) + }), + { + config: { + mcp: { + demo: { + type: "local", + command: ["echo", "demo"], + enabled: false, + }, + }, + }, + }, + ) + + it.instance( + "serves add, connect, and disconnect endpoints", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const handler = HttpApiApp.webHandler() + const added = yield* request(handler, McpPaths.status, tmp.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "added", + config: { + type: "local", + command: ["echo", "added"], + enabled: false, + }, + }), + }) + expect(added.status).toBe(200) + expect(yield* json(added)).toMatchObject({ added: { status: "disabled" } }) + + const addedDisconnected = yield* request(handler, "/mcp/added/disconnect", tmp.directory, { method: "POST" }) + expect(addedDisconnected.status).toBe(200) + expect(yield* json(addedDisconnected)).toBe(true) + + const connected = yield* request(handler, "/mcp/demo/connect", tmp.directory, { method: "POST" }) + expect(connected.status).toBe(200) + expect(yield* json(connected)).toBe(true) + + const disconnected = yield* request(handler, "/mcp/demo/disconnect", tmp.directory, { method: "POST" }) + expect(disconnected.status).toBe(200) + expect(yield* json(disconnected)).toBe(true) + }), + { + config: { + mcp: { + demo: { + type: "local", + command: ["echo", "demo"], + enabled: false, + }, + }, + }, + }, + ) + + it.instance( + "serves deterministic OAuth endpoints", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const handler = HttpApiApp.webHandler() + const start = yield* request(handler, "/mcp/demo/auth", tmp.directory, { method: "POST" }) + expect(start.status).toBe(400) + + const authenticate = yield* request(handler, "/mcp/demo/auth/authenticate", tmp.directory, { method: "POST" }) + expect(authenticate.status).toBe(400) + + const removed = yield* request(handler, "/mcp/demo/auth", tmp.directory, { method: "DELETE" }) + expect(removed.status).toBe(200) + expect(yield* json(removed)).toEqual({ success: true }) + }), + { + config: { + mcp: { + demo: { + type: "local", + command: ["echo", "demo"], + enabled: false, + }, + }, + }, + }, + ) + + it.instance( + "returns unsupported OAuth error responses", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const dir = tmp.directory + const headers = { "x-opencode-directory": dir } + + yield* Effect.forEach(["/mcp/demo/auth", "/mcp/demo/auth/authenticate"], (path) => + Effect.gen(function* () { + const response = yield* readResponse({ app: app(), path, headers }) + + expect(response).toEqual({ + status: 400, + body: JSON.stringify({ error: "MCP server demo does not support OAuth" }), + }) + }), + ) + }), + { + config: { + formatter: false, + lsp: false, + mcp: { + demo: { + type: "local", + command: ["echo", "demo"], + enabled: false, + }, + }, + }, + }, + ) + + it.instance( + "returns typed not found errors for missing MCP servers", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const handler = HttpApiApp.webHandler() + + for (const input of [ + { method: "POST", route: "/mcp/missing/auth" }, + { method: "POST", route: "/mcp/missing/auth/authenticate" }, + { method: "POST", route: "/mcp/missing/auth/callback", body: JSON.stringify({ code: "code" }) }, + { method: "DELETE", route: "/mcp/missing/auth" }, + { method: "POST", route: "/mcp/missing/connect" }, + { method: "POST", route: "/mcp/missing/disconnect" }, + ]) { + const response = yield* request(handler, input.route, tmp.directory, { + method: input.method, + headers: input.body ? { "content-type": "application/json" } : undefined, + body: input.body, + }) + + expect(response.status).toBe(404) + expect(yield* json(response)).toEqual({ + _tag: "McpServerNotFoundError", + name: "missing", + message: "MCP server not found: missing", + }) + } + }), + { config: { mcp: {} } }, + ) +}) diff --git a/packages/opencode/test/server/httpapi-mdns.test.ts b/packages/opencode/test/server/httpapi-mdns.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6dc122384e98fb2fd08101d0e5fc9a9016a7ddf1 --- /dev/null +++ b/packages/opencode/test/server/httpapi-mdns.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import { withTimeout } from "../../src/util/timeout" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances } from "../fixture/fixture" + +type Event = { kind: "publish"; port: number; name: string } | { kind: "unpublishAll" } | { kind: "destroy" } +const events: Event[] = [] + +void mock.module("bonjour-service", () => ({ + Bonjour: class { + publish(opts: { port: number; name: string }) { + events.push({ kind: "publish", port: opts.port, name: opts.name }) + return { on: () => {} } + } + unpublishAll() { + events.push({ kind: "unpublishAll" }) + } + destroy() { + events.push({ kind: "destroy" }) + } + }, +})) + +// Import Server AFTER the mock so the MDNS module picks up the stub. +const { Server } = await import("../../src/server/server") + +const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +afterEach(async () => { + events.length = 0 + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + await disposeAllInstances() + await resetDatabase() +}) + +describe("HttpApi Server.listen mDNS", () => { + test("skips publish for loopback hostnames", async () => { + Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" + Flag.OPENCODE_SERVER_USERNAME = "opencode" + const listener = await Server.listen({ hostname: "127.0.0.1", port: 0, mdns: true }) + try { + expect(events.filter((e) => e.kind === "publish")).toEqual([]) + } finally { + await withTimeout(listener.stop(true), 10_000, "timed out stopping loopback mdns listener") + } + expect(events.filter((e) => e.kind === "publish")).toEqual([]) + }) + + test("publishes for non-loopback hostnames and unpublishes on stop", async () => { + Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" + Flag.OPENCODE_SERVER_USERNAME = "opencode" + const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true }) + try { + const published = events.filter((e) => e.kind === "publish") + expect(published.length).toBe(1) + expect(published[0]!.port).toBe(listener.port) + expect(published[0]!.name).toBe(`opencode-${listener.port}`) + } finally { + await withTimeout(listener.stop(true), 10_000, "timed out stopping mdns listener") + } + expect(events.some((e) => e.kind === "unpublishAll")).toBe(true) + expect(events.some((e) => e.kind === "destroy")).toBe(true) + }) + + test("scope finalizer unpublishes even if stop() is not called for force-close", async () => { + Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" + Flag.OPENCODE_SERVER_USERNAME = "opencode" + const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true }) + expect(events.filter((e) => e.kind === "publish").length).toBe(1) + // Plain (graceful) stop without close=true should still unpublish. + await withTimeout(listener.stop(), 10_000, "timed out stopping graceful mdns listener") + expect(events.some((e) => e.kind === "unpublishAll")).toBe(true) + }) +}) diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..044a77fd22253defcabc02e32500eae49498181a --- /dev/null +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -0,0 +1,212 @@ +// Regression coverage for issue #26526's claim that promptAsync's +// Effect.forkIn loses the request's InstanceRef/WorkspaceRef. It does not — +// forkIn preserves Context.Reference values via standard fiber inheritance. +// +// The companion claim that the streaming prompt handler "captures and +// provides" those services is true and load-bearing: Stream.fromEffect's +// body runs detached from the request fiber's context, so the explicit +// Effect.provideService calls there are required, not defensive duplication. + +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Deferred, Effect, Layer, Schema, Scope } from "effect" +import * as Stream from "effect/Stream" +import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { mkdir } from "node:fs/promises" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" +import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { Project } from "../../src/project/project" +import { Session } from "../../src/session/session" +import { + InstanceContextMiddleware, + instanceContextLayer, +} from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + workspaceRoutingLayer, +} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await disposeAllInstances() + await resetDatabase() + }), + ) + }), +) + +const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true }) + +const it = testEffect(Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, workspaceLayer)) + +const instanceContextTestLayer = Layer.mergeAll( + instanceContextLayer, + workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), +) + +const localAdapter = (directory: string): WorkspaceAdapter => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const setupWorkspace = (kind: string) => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Project.use.fromDirectory(dir) + const projectID = yield* Project.Service.use((svc) => svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id))) + registerAdapter(projectID, kind, localAdapter(dir)) + const workspace = yield* Workspace.Service.use((svc) => + svc.create({ type: kind, branch: null, extra: null, projectID }), + ) + return { dir, workspace } + }) + +type Capture = { directory?: string; workspaceID?: string } + +const captureInstance = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return { directory: instance?.directory, workspaceID } satisfies Capture +}) + +const ProbeApi = HttpApi.make("handler-context-probe").add( + HttpApiGroup.make("probe") + .add( + HttpApiEndpoint.post("fork", "/fork-probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }), + HttpApiEndpoint.post("streamWithout", "/stream-probe-without", { + query: WorkspaceRoutingQuery, + success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })), + }), + HttpApiEndpoint.post("streamWith", "/stream-probe-with", { + query: WorkspaceRoutingQuery, + success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })), + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware), +) + +const serveProbes = (input: { + fork?: Effect.Effect + streamWithout?: Effect.Effect + streamWith?: Effect.Effect +}) => + HttpApiBuilder.layer(ProbeApi).pipe( + Layer.provide( + HttpApiBuilder.group(ProbeApi, "probe", (handlers) => + handlers + .handle("fork", () => input.fork ?? Effect.succeed(false)) + .handleRaw( + "streamWithout", + () => input.streamWithout ?? Effect.succeed(HttpServerResponse.empty({ status: 404 })), + ) + .handleRaw("streamWith", () => input.streamWith ?? Effect.succeed(HttpServerResponse.empty({ status: 404 }))), + ), + ), + Layer.provide(instanceContextTestLayer), + Layer.provide(Layer.mock(Session.Service)({})), + HttpRouter.serve, + Layer.build, + ) + +describe("HttpApi handler context inheritance", () => { + // Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits + // the request's Context — including InstanceRef and WorkspaceRef provided + // by InstanceContextMiddleware — without any explicit re-provide. + it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-fork") + const capture = yield* Deferred.make() + + yield* serveProbes({ + fork: Effect.gen(function* () { + const scope = yield* Scope.Scope + yield* Effect.gen(function* () { + yield* Deferred.succeed(capture, yield* captureInstance) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return true + }), + }) + + const response = yield* HttpClient.post( + `/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`, + ) + expect(response.status).toBe(200) + + const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds")) + expect(observed.directory).toBe(dir) + expect(observed.workspaceID).toBe(workspace.id) + }), + ) + + // Mirrors handlers/session.ts:255 prompt — the streaming handler reads + // InstanceRef/WorkspaceRef in the request fiber and re-provides them to + // the Stream.fromEffect body. This test locks in why the explicit + // provides are required: without them the stream body sees undefined. + it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-stream") + const withoutCapture = yield* Deferred.make() + const withCapture = yield* Deferred.make() + + yield* serveProbes({ + streamWithout: Effect.gen(function* () { + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withoutCapture, yield* captureInstance) + return "" + }), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + streamWith: Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withCapture, yield* captureInstance) + return "" + }).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + }) + + const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}` + const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`) + yield* responseWithout.text + const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`) + yield* responseWith.text + + const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds")) + expect(without.directory).toBeUndefined() + expect(without.workspaceID).toBeUndefined() + + const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds")) + expect(withProvide.directory).toBe(dir) + expect(withProvide.workspaceID).toBe(workspace.id) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..625ca1896b469d30e827891eb20667ffe10d3c07 --- /dev/null +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -0,0 +1,401 @@ +import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect, Layer } from "effect" +import path from "path" +import { resetDatabase } from "../fixture/db" +import { TestInstance } from "../fixture/fixture" +import { markPluginDependenciesReady } from "../fixture/plugin" +import { testEffect } from "../lib/effect" +import { httpApiLayer, request } from "./httpapi-layer" + +const testStateLayer = Layer.effectDiscard( + Effect.acquireRelease( + Effect.promise(() => resetDatabase()), + () => Effect.promise(() => resetDatabase()), + ), +) + +const it = testEffect(Layer.mergeAll(testStateLayer, LayerNode.compile(FSUtil.node), httpApiLayer)) +const projectOptions = { config: { formatter: false, lsp: false } } +const providerID = "test-oauth-parity" +const oauthURL = "https://example.com/oauth" +const oauthInstructions = "Finish OAuth" + +function providerListHasFetch(list: unknown) { + if (!Array.isArray(list)) return false + return list.some((item: unknown) => { + if (typeof item !== "object" || item === null || !("id" in item) || !("options" in item)) return false + if (item.id !== "google") return false + if (typeof item.options !== "object" || item.options === null) return false + return "fetch" in item.options + }) +} + +function hasProviderWithFetch(input: unknown, key: "all" | "providers") { + if (typeof input !== "object" || input === null) return false + if (key === "all") return "all" in input && providerListHasFetch(input.all) + return "providers" in input && providerListHasFetch(input.providers) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function providerList(input: unknown, key: "all" | "providers") { + if (!isRecord(input)) return [] + if (!Array.isArray(input[key])) return [] + return input[key] +} + +function providerByID(input: unknown, key: "all" | "providers", id: string) { + return providerList(input, key).find((provider) => isRecord(provider) && provider.id === id) +} + +function hasNonZeroModelCost(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider) || !isRecord(provider.models)) return false + return Object.values(provider.models).some((model) => { + if (!isRecord(model) || !isRecord(model.cost) || !isRecord(model.cost.cache)) return false + return [model.cost.input, model.cost.output, model.cost.cache.read, model.cost.cache.write].some( + (cost) => typeof cost === "number" && cost > 0, + ) + }) +} + +function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider)) return false + if (provider.name === "mutated-provider") return true + return isRecord(provider.options) && provider.options.mutatedByPlugin === true +} + +function requestAuthorize(input: { + providerID: string + method: number + headers: HeadersInit + inputs?: Record +}) { + return Effect.gen(function* () { + const response = yield* request(`/provider/${input.providerID}/oauth/authorize`, { + method: "POST", + headers: input.headers, + body: JSON.stringify({ method: input.method, ...(input.inputs ? { inputs: input.inputs } : {}) }), + }) + return { + status: response.status, + body: yield* response.text, + } + }) +} + +function requestCallback(input: { providerID: string; method: number; headers: HeadersInit; code?: string }) { + return Effect.gen(function* () { + const response = yield* request(`/provider/${input.providerID}/oauth/callback`, { + method: "POST", + headers: input.headers, + body: JSON.stringify({ method: input.method, ...(input.code ? { code: input.code } : {}) }), + }) + return { + status: response.status, + body: yield* response.text, + } + }) +} + +function writeProviderAuthPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FSUtil.Service + yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode"))) + + yield* fs.writeWithDirs( + path.join(dir, ".opencode", "plugin", "provider-oauth-parity.ts"), + [ + "export default {", + ' id: "test.provider-oauth-parity",', + " server: async () => ({", + " auth: {", + ` provider: "${providerID}",`, + " methods: [", + ' { type: "api", label: "API key" },', + " {", + ' type: "oauth",', + ' label: "OAuth",', + " authorize: async () => ({", + ` url: "${oauthURL}",`, + ' method: "code",', + ` instructions: "${oauthInstructions}",`, + " callback: async () => ({ type: 'success', key: 'token' }),", + " }),", + " },", + " ],", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + +function writeProviderAuthValidationPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FSUtil.Service + yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode"))) + + yield* fs.writeWithDirs( + path.join(dir, ".opencode", "plugin", "provider-oauth-validation.ts"), + [ + "export default {", + ' id: "test.provider-oauth-validation",', + " server: async () => ({", + " auth: {", + ' provider: "test-oauth-validation",', + " methods: [", + " {", + ' type: "oauth",', + ' label: "OAuth",', + " prompts: [", + " {", + ' type: "text",', + ' key: "token",', + ' message: "Token",', + " validate: (value) => value === 'ok' ? undefined : 'Token must be ok',", + " },", + " ],", + " authorize: async () => ({", + ` url: "${oauthURL}",`, + ' method: "code",', + ` instructions: "${oauthInstructions}",`, + " callback: async () => ({ type: 'success', key: 'token' }),", + " }),", + " },", + " ],", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + +function writeFunctionOptionsPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FSUtil.Service + yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode"))) + + yield* fs.writeWithDirs( + path.join(dir, ".opencode", "plugin", "provider-function-options.ts"), + [ + "export default {", + ' id: "test.provider-function-options",', + " server: async () => ({", + " auth: {", + ' provider: "google",', + " loader: async (_getAuth, provider) => {", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return {", + ' apiKey: "",', + " fetch: async (input, init) => fetch(input, init),", + " }", + " },", + " methods: [{ type: 'api', label: 'API key' }],", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + +function writeProviderModelsMutationPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FSUtil.Service + yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode"))) + + yield* fs.writeWithDirs( + path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"), + [ + "export default {", + ' id: "test.provider-models-mutation",', + " server: async () => ({", + " provider: {", + ' id: "google",', + " models: async (provider) => {", + " const models = Object.fromEntries(", + " Object.entries(provider.models ?? {}).map(([id, model]) => [id, { ...model }]),", + " )", + ' provider.name = "mutated-provider"', + " provider.options = { ...provider.options, mutatedByPlugin: true }", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return models", + " },", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + +function setEnvScoped(key: string, value: string) { + return Effect.acquireRelease( + Effect.sync(() => { + const previous = process.env[key] + process.env[key] = value + return previous + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env[key] + else process.env[key] = previous + }), + ) +} + +describe("provider HttpApi", () => { + it.instance.skip( + "returns public v2 provider not found errors", + Effect.gen(function* () { + const directory = (yield* TestInstance).directory + const response = yield* request("/api/provider/missing", { + headers: { "x-opencode-directory": directory }, + }) + + expect(response.status).toBe(404) + expect(yield* response.json).toEqual({ + _tag: "ProviderNotFoundError", + providerID: "missing", + message: "Provider not found: missing", + }) + }), + projectOptions, + ) + + it.instance( + "serves OAuth authorize response shapes", + Effect.gen(function* () { + const directory = (yield* TestInstance).directory + const headers = { "x-opencode-directory": directory, "content-type": "application/json" } + const api = yield* requestAuthorize({ + providerID, + method: 0, + headers, + }) + // method 0 (api-key style) — authorize() resolves with no further + // redirect; #26474 changed the wire format to JSON `null` so clients + // can `.json()` parse uniformly instead of getting an empty body + // that throws. + expect(api).toEqual({ status: 200, body: "null" }) + + const oauth = yield* requestAuthorize({ + providerID, + method: 1, + headers, + }) + expect(JSON.parse(oauth.body)).toEqual({ + url: oauthURL, + method: "code", + instructions: oauthInstructions, + }) + }), + { ...projectOptions, init: writeProviderAuthPlugin }, + 30000, + ) + + it.instance( + "returns declared provider auth validation errors", + Effect.gen(function* () { + const directory = (yield* TestInstance).directory + const response = yield* requestAuthorize({ + providerID: "test-oauth-validation", + method: 0, + inputs: { token: "nope" }, + headers: { "x-opencode-directory": directory, "content-type": "application/json" }, + }) + + expect(response.status).toBe(400) + expect(JSON.parse(response.body)).toEqual({ + name: "ProviderAuthValidationFailed", + data: { field: "token", message: "Token must be ok" }, + }) + }), + { ...projectOptions, init: writeProviderAuthValidationPlugin }, + 30000, + ) + + it.instance( + "returns declared provider auth callback errors", + Effect.gen(function* () { + const directory = (yield* TestInstance).directory + const response = yield* requestCallback({ + providerID, + method: 0, + headers: { "x-opencode-directory": directory, "content-type": "application/json" }, + }) + + expect(response.status).toBe(400) + expect(JSON.parse(response.body)).toEqual({ + name: "ProviderAuthOauthMissing", + data: { providerID }, + }) + }), + projectOptions, + 30000, + ) + + it.instance( + "serves provider lists when auth loaders add runtime fetch options", + Effect.gen(function* () { + const directory = (yield* TestInstance).directory + yield* setEnvScoped( + "OPENCODE_AUTH_CONTENT", + JSON.stringify({ + google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 }, + }), + ) + const headers = { "x-opencode-directory": directory } + const providerResponse = yield* request("/provider", { headers }) + const configResponse = yield* request("/config/providers", { headers }) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* providerResponse.json + const configBody = yield* configResponse.json + expect(hasProviderWithFetch(providerBody, "all")).toBe(false) + expect(hasProviderWithFetch(configBody, "providers")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) + expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true) + }), + { ...projectOptions, init: writeFunctionOptionsPlugin }, + ) + + it.instance( + "keeps provider.models hook input mutations out of provider state", + Effect.gen(function* () { + const directory = (yield* TestInstance).directory + + const headers = { "x-opencode-directory": directory } + const providerResponse = yield* request("/provider", { headers }) + const configResponse = yield* request("/config/providers", { headers }) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* providerResponse.json + const configBody = yield* configResponse.json + expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false) + expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) + }), + { ...projectOptions, init: writeProviderModelsMutationPlugin }, + ) +}) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3eec0c9682f71ca63dd4ee90100387d837e7b6f1 --- /dev/null +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -0,0 +1,299 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { PtyID } from "@opencode-ai/core/pty/schema" +import { Server } from "../../src/server/server" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture" +import { Config, Effect, Layer, Queue, Schema } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { Pty } from "@opencode-ai/core/pty" +import { testEffect } from "../lib/effect" + +const testPty = process.platform === "win32" ? test.skip : test + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await resetDatabase() + }), + ) + }), +) + +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { disableListenLog: true, disableLogger: true }, +) + +const effectIt = testEffect( + Layer.mergeAll( + testStateLayer, + Socket.layerWebSocketConstructorGlobal, + servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), + ), + ), +) + +function app() { + return Server.Default().app +} + +function serverUrl() { + return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) +} + +const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("pty HttpApi bridge", () => { + test("serves available shell list through experimental Effect routes", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const response = await app().request(PtyPaths.shells, { headers: { "x-opencode-directory": tmp.path } }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: expect.any(String), + name: expect.any(String), + acceptable: expect.any(Boolean), + }), + ]), + ) + }) + + testPty("serves PTY JSON routes through experimental Effect routes", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path } + const list = await app().request(PtyPaths.list, { headers }) + expect(list.status).toBe(200) + expect(await list.json()).toEqual([]) + + const created = await app().request(PtyPaths.create, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"], title: "demo" }), + }) + expect(created.status).toBe(200) + const info = await created.json() + + try { + expect(info).toMatchObject({ title: "demo", command: "/usr/bin/env", status: "running" }) + + const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers }) + expect(found.status).toBe(200) + expect(await found.json()).toMatchObject({ id: info.id, title: "demo" }) + + const updated = await app().request(PtyPaths.update.replace(":ptyID", info.id), { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ title: "renamed", size: { cols: 80, rows: 24 } }), + }) + expect(updated.status).toBe(200) + expect(await updated.json()).toMatchObject({ id: info.id, title: "renamed" }) + } finally { + await app().request(PtyPaths.remove.replace(":ptyID", info.id), { method: "DELETE", headers }) + } + + const missing = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers }) + expect(missing.status).toBe(404) + expect(await missing.json()).toEqual({ + _tag: "PtyNotFoundError", + ptyID: info.id, + message: `PTY session not found: ${info.id}`, + }) + + const missingUpdate = await app().request(PtyPaths.update.replace(":ptyID", info.id), { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ title: "missing" }), + }) + expect(missingUpdate.status).toBe(404) + expect(await missingUpdate.json()).toEqual({ + _tag: "PtyNotFoundError", + ptyID: info.id, + message: `PTY session not found: ${info.id}`, + }) + + const missingRemove = await app().request(PtyPaths.remove.replace(":ptyID", info.id), { method: "DELETE", headers }) + expect(missingRemove.status).toBe(404) + expect(await missingRemove.json()).toEqual({ + _tag: "PtyNotFoundError", + ptyID: info.id, + message: `PTY session not found: ${info.id}`, + }) + }) + + testPty("hides exited sessions on the legacy surface", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path } + const created = await app().request(PtyPaths.create, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "exit 0"] }), + }) + expect(created.status).toBe(200) + const info = await created.json() + + // Exited sessions are retained by core for the canonical surface, but the legacy + // routes preserve pre-retention behavior: exited sessions are invisible here. + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers }) + if (found.status === 404) break + await new Promise((resolve) => setTimeout(resolve, 50)) + } + const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers }) + expect(found.status).toBe(404) + + const list = await app().request(PtyPaths.list, { headers }) + expect(list.status).toBe(200) + expect(await list.json()).toEqual([]) + }) + + testPty("disposes PTY sessions with their legacy instance", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path } + const created = await app().request(PtyPaths.create, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"] }), + }) + expect(created.status).toBe(200) + + await disposeAllInstances() + + const list = await app().request(PtyPaths.list, { headers }) + expect(list.status).toBe(200) + expect(await list.json()).toEqual([]) + }) + + test("returns 404 for missing PTY websocket before upgrade", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), { + headers: { "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(404) + }) + + test("returns 404 for missing PTY websocket before decoding cursor query", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const response = await app().request(`${PtyPaths.connect.replace(":ptyID", PtyID.ascending())}?cursor=a&cursor=b`, { + headers: { "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(404) + }) + + test("returns typed not found errors for missing PTY HTTP resources", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path } + const missingID = String(PtyID.ascending()) + const expected = { + _tag: "PtyNotFoundError", + ptyID: missingID, + message: `PTY session not found: ${missingID}`, + } + + const found = await app().request(PtyPaths.get.replace(":ptyID", missingID), { headers }) + expect(found.status).toBe(404) + expect(await found.json()).toEqual(expected) + + const updated = await app().request(PtyPaths.update.replace(":ptyID", missingID), { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ title: "missing" }), + }) + expect(updated.status).toBe(404) + expect(await updated.json()).toEqual(expected) + + const removed = await app().request(PtyPaths.remove.replace(":ptyID", missingID), { method: "DELETE", headers }) + expect(removed.status).toBe(404) + expect(await removed.json()).toEqual(expected) + }) + + test("returns typed errors for PTY connect token failures", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path } + const missingID = String(PtyID.ascending()) + + const forbidden = await app().request(PtyPaths.connectToken.replace(":ptyID", missingID), { + method: "POST", + headers, + }) + expect(forbidden.status).toBe(403) + expect(await forbidden.json()).toEqual({ + _tag: "PtyForbiddenError", + message: "Invalid PTY connect token request", + }) + + const missing = await app().request(PtyPaths.connectToken.replace(":ptyID", missingID), { + method: "POST", + headers: { + ...headers, + "x-opencode-ticket": "1", + }, + }) + expect(missing.status).toBe(404) + expect(await missing.json()).toEqual({ + _tag: "PtyNotFoundError", + ptyID: missingID, + message: `PTY session not found: ${missingID}`, + }) + }) + ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)( + "serves PTY websocket output and input through Effect routes", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } }) + const created = yield* HttpClientRequest.post(PtyPaths.create).pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ command: "/bin/cat", title: "websocket" }), + Effect.flatMap(HttpClient.execute), + ) + expect(created.status).toBe(200) + const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json) + + const socket = yield* Socket.makeWebSocket( + `${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=-1&directory=${encodeURIComponent(dir)}`, + { closeCodeIsError: () => false }, + ) + const messages = yield* Queue.unbounded() + yield* socket + .runRaw((message) => + Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)), + ) + .pipe(Effect.catch(() => Effect.void)) + .pipe(Effect.forkScoped) + const write = yield* socket.writer + + const takeUntil = (expected: string, seen = ""): Effect.Effect => + Effect.gen(function* () { + const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds"))) + if (next.includes(expected)) return next + return yield* takeUntil(expected, next) + }) + + yield* write("ping-route\n") + expect(yield* takeUntil("ping-route")).toContain("ping-route") + yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) + + const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe( + directoryHeader(dir), + HttpClient.execute, + ) + expect(removed.status).toBe(200) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..310ebae5957cbbe3c8ec595d42c899b83e78af8a --- /dev/null +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, test } from "bun:test" +import { OpenApi } from "effect/unstable/httpapi" +import { PublicApi } from "../../src/server/routes/instance/httpapi/public" + +type Method = "get" | "post" | "put" | "delete" | "patch" +type OpenApiSchema = { + readonly $ref?: string + readonly anyOf?: ReadonlyArray + readonly type?: string + readonly enum?: readonly unknown[] + readonly properties?: Record + readonly required?: readonly string[] + readonly contentSchema?: OpenApiSchema + readonly contentMediaType?: string +} +type OpenApiResponse = { + readonly description?: string + readonly content?: Record +} +type OpenApiOperation = { + readonly parameters?: ReadonlyArray<{ + readonly name: string + readonly in: string + readonly required?: boolean + readonly schema?: { readonly type?: string } + }> + readonly responses?: Record + readonly requestBody?: { readonly required?: boolean } + readonly security?: unknown +} +type OpenApiPathItem = Partial> +type OpenApiSpec = { + readonly paths: Record + readonly components: { readonly schemas: Record } +} + +const methods = ["get", "post", "put", "delete", "patch"] as const + +const allowedV2BuiltInEndpointErrors: string[] = [] + +function v2Operations(spec: OpenApiSpec) { + return Object.entries(spec.paths).flatMap(([path, item]) => + path.startsWith("/api/") + ? methods.flatMap((method) => { + const operation = item[method] + return operation ? [{ method, path, operation }] : [] + }) + : [], + ) +} + +function responseRef(response: OpenApiResponse | undefined) { + return response?.content?.["application/json"]?.schema?.$ref +} + +function componentName(ref: string) { + return ref.replace("#/components/schemas/", "") +} + +function componentNames(response: OpenApiResponse | undefined) { + const schema = response?.content?.["application/json"]?.schema + if (!schema) return [] + return [ + ...new Set([schema, ...(schema.anyOf ?? [])].flatMap((item) => (item.$ref ? [componentName(item.$ref)] : []))), + ] +} + +function isBuiltInEndpointError(name: string) { + return name.startsWith("EffectHttpApiError") || name.startsWith("effect_HttpApiError_") +} + +describe("PublicApi OpenAPI v2 errors", () => { + test("includes plugin-facing core schemas", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + expect(Object.keys(spec.components.schemas)).toEqual( + expect.arrayContaining([ + "CredentialValue", + "IntegrationInputs", + "IntegrationMethod", + "IntegrationRef", + "SkillV2Source", + ]), + ) + }) + + test("documents nested legacy global sync events", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + const schema = spec.components.schemas.SyncEventSessionCreated + + expect(schema?.required).toEqual(["type", "id", "syncEvent"]) + expect(schema?.properties?.type?.enum).toEqual(["sync"]) + expect(schema?.properties?.syncEvent).toMatchObject({ + required: ["type", "id", "seq", "aggregateID", "data"], + properties: { + type: { enum: ["session.created.1"] }, + id: { type: "string" }, + seq: { type: "number" }, + aggregateID: { type: "string" }, + }, + }) + }) + + test("names the v2 event union without the SSE string wrapper collision", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + expect(spec.components.schemas.V2Event1).toBeUndefined() + expect(spec.components.schemas.V2Event?.anyOf?.length).toBeGreaterThan(0) + expect(spec.components.schemas.V2EventStream).toMatchObject({ + type: "string", + contentMediaType: "application/json", + contentSchema: { $ref: "#/components/schemas/V2Event" }, + }) + expect(spec.paths["/api/event"]?.get?.responses?.["200"]?.content?.["text/event-stream"]?.schema).toEqual({ + $ref: "#/components/schemas/V2Event", + }) + }) + + test("preserves /api auth responses", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + for (const route of v2Operations(spec)) { + expect(route.operation.responses?.["401"], `${route.method.toUpperCase()} ${route.path}`).toBeDefined() + expect(route.operation.security, `${route.method.toUpperCase()} ${route.path}`).toEqual([]) + } + }) + + test("documents references separately from filesystem routes", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + for (const path of ["/api/fs/read/*", "/api/fs/list"]) { + expect(spec.paths[path]?.get?.parameters, path).not.toContainEqual(expect.objectContaining({ name: "reference" })) + } + expect(spec.paths["/api/reference"]?.get).toBeDefined() + }) + + test("preserves required request bodies for v2 mutations", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + for (const path of [ + "/api/session/{sessionID}/prompt", + "/api/session/{sessionID}/permission/{requestID}/reply", + "/api/session/{sessionID}/question/{requestID}/reply", + ]) { + expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true) + } + }) + + test("documents integration discovery and connection routes", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + for (const [method, path] of [ + ["get", "/api/integration"], + ["get", "/api/integration/{integrationID}"], + ["post", "/api/integration/{integrationID}/connect/key"], + ["post", "/api/integration/{integrationID}/connect/oauth"], + ["get", "/api/integration/attempt/{attemptID}"], + ["post", "/api/integration/attempt/{attemptID}/complete"], + ["delete", "/api/integration/attempt/{attemptID}"], + ["delete", "/api/credential/{credentialID}"], + ["patch", "/api/credential/{credentialID}"], + ] as const) { + expect(spec.paths[path]?.[method], `${method.toUpperCase()} ${path}`).toBeDefined() + } + + for (const path of [ + "/api/integration/{integrationID}/connect/key", + "/api/integration/{integrationID}/connect/oauth", + "/api/integration/attempt/{attemptID}/complete", + ]) { + expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true) + } + }) + + test("does not rewrite /api endpoint errors to legacy error components", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + const refs = v2Operations(spec) + .flatMap((route) => + Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => { + const ref = responseRef(response) + return ref ? [`${route.method.toUpperCase()} ${route.path} ${status} ${componentName(ref)}`] : [] + }), + ) + .filter((entry) => entry.endsWith(" BadRequestError") || entry.endsWith(" NotFoundError")) + + expect(refs).toEqual([]) + }) + + test("new /api endpoint errors cannot use built-in components without an explicit allowlist", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + const builtInEndpointErrors = v2Operations(spec) + .flatMap((route) => + Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => { + if (status === "401") return [] + const ref = responseRef(response) + if (!ref) return [] + const name = componentName(ref) + return isBuiltInEndpointError(name) ? [`${route.method.toUpperCase()} ${route.path} ${status} ${name}`] : [] + }), + ) + .sort() + + expect(builtInEndpointErrors).toEqual(allowedV2BuiltInEndpointErrors) + }) + + test("documents v2 provider and model catalog errors", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + expect(componentName(responseRef(spec.paths["/api/provider"]?.get?.responses?.["503"]) ?? "")).toBe( + "ServiceUnavailableError", + ) + expect(componentName(responseRef(spec.paths["/api/model"]?.get?.responses?.["503"]) ?? "")).toBe( + "ServiceUnavailableError", + ) + expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["404"]) ?? "")).toBe( + "ProviderNotFoundError", + ) + expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["503"]) ?? "")).toBe( + "ServiceUnavailableError", + ) + }) + + test("documents v2 session not-found errors", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + for (const route of [ + ["post", "/api/session/{sessionID}/prompt"], + ["post", "/api/session/{sessionID}/compact"], + ["post", "/api/session/{sessionID}/wait"], + ["get", "/api/session/{sessionID}/context"], + ["get", "/api/session/{sessionID}/message"], + ] as const) { + expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toContain("SessionNotFoundError") + } + }) + + test("documents v2 unfinished session mutation errors", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + for (const route of [ + ["post", "/api/session/{sessionID}/compact"], + ["post", "/api/session/{sessionID}/wait"], + ] as const) { + expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["503"]) ?? "")).toBe( + "ServiceUnavailableError", + ) + } + }) + + test("documents v2 session read data errors", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + for (const route of [ + ["get", "/api/session/{sessionID}/context"], + ["get", "/api/session/{sessionID}/message"], + ] as const) { + expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["500"]) ?? "")).toMatch( + /^UnknownError\d*$/, + ) + } + }) + + test("documents session busy errors", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + for (const route of [ + ["post", "/session/{sessionID}/shell"], + ["post", "/session/{sessionID}/revert"], + ["post", "/session/{sessionID}/unrevert"], + ["delete", "/session/{sessionID}/message/{messageID}"], + ] as const) { + expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["409"]) ?? "")).toBe( + "SessionBusyError", + ) + } + }) + + test("documents permission and question not-found errors", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + expect( + componentName(responseRef(spec.paths["/permission/{requestID}/reply"]?.post?.responses?.["404"]) ?? ""), + ).toBe("PermissionNotFoundError") + for (const route of [ + ["post", "/question/{requestID}/reply"], + ["post", "/question/{requestID}/reject"], + ] as const) { + expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe( + "QuestionNotFoundError", + ) + } + for (const route of [ + ["post", "/api/session/{sessionID}/question/{requestID}/reply"], + ["post", "/api/session/{sessionID}/question/{requestID}/reject"], + ] as const) { + expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toEqual([ + "QuestionNotFoundError", + "SessionNotFoundError", + ]) + } + }) + + test("documents MCP server not-found errors", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + for (const route of [ + ["post", "/mcp/{name}/auth"], + ["post", "/mcp/{name}/auth/authenticate"], + ["post", "/mcp/{name}/auth/callback"], + ["delete", "/mcp/{name}/auth"], + ["post", "/mcp/{name}/connect"], + ["post", "/mcp/{name}/disconnect"], + ] as const) { + expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe( + "McpServerNotFoundError", + ) + } + }) + + test("documents PTY resource and ticket errors", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + for (const route of [ + ["get", "/pty/{ptyID}"], + ["put", "/pty/{ptyID}"], + ["delete", "/pty/{ptyID}"], + ["post", "/pty/{ptyID}/connect-token"], + ] as const) { + expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe( + "PtyNotFoundError", + ) + } + expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe( + "PtyForbiddenError", + ) + expect( + spec.paths["/pty/{ptyID}/connect"]?.get?.parameters + ?.filter((parameter) => parameter.in === "query") + .map((parameter) => parameter.name), + ).toEqual(["directory", "workspace", "cursor", "ticket"]) + }) + + test("documents project not-found errors", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + expect(componentName(responseRef(spec.paths["/project/{projectID}"]?.patch?.responses?.["404"]) ?? "")).toBe( + "ProjectNotFoundError", + ) + }) +}) diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5411767ed1f074b0eec524c214c568ce6d6065c --- /dev/null +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -0,0 +1,330 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { OpenApi } from "effect/unstable/httpapi" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Server } from "../../src/server/server" +import { SessionID } from "../../src/session/schema" +import { PublicApi } from "../../src/server/routes/instance/httpapi/public" +import { + FilePaths, + FileQuery, + FindFileQuery, + FindTextQuery, +} from "../../src/server/routes/instance/httpapi/groups/file" +import { + ExperimentalPaths, + SessionListQuery as ExperimentalSessionListQuery, + ToolListQuery, +} from "../../src/server/routes/instance/httpapi/groups/experimental" +import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" +import { + ListQuery as SessionListQuery, + MessagesQuery, + SessionPaths, +} from "../../src/server/routes/instance/httpapi/groups/session" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" +import { SessionMessagesQuery } from "@opencode-ai/protocol/groups/message" +import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + +type Method = "get" | "post" | "put" | "delete" | "patch" +type QuerySchema = { readonly fields: Record } +type OpenApiSchema = { + readonly anyOf?: readonly OpenApiSchema[] + readonly enum?: readonly string[] + readonly maximum?: number + readonly minimum?: number + readonly pattern?: string + readonly type?: string +} +type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema } +type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } + +const openApiDriftRoutes = [ + { method: "get", path: SessionPaths.list, query: SessionListQuery }, + { method: "get", path: SessionPaths.messages, query: MessagesQuery }, + { method: "get", path: FilePaths.findFile, query: FindFileQuery }, + { method: "get", path: FilePaths.findText, query: FindTextQuery }, + { method: "get", path: FilePaths.list, query: FileQuery }, + { method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery }, + { method: "get", path: ExperimentalPaths.tool, query: ToolListQuery }, + { method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery }, + { method: "get", path: "/api/session/:sessionID/message", query: SessionMessagesQuery }, +] satisfies Array<{ method: Method; path: string; query: QuerySchema }> + +const numericSdkQueryParams = [ + { method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } }, + { method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } }, + { method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } }, + { method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } }, + { + method: "get", + path: SessionPaths.messages, + name: "limit", + schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + }, + { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, +] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> + +const booleanSdkQueryParams = [ + { method: "get", path: ExperimentalPaths.session, name: "roots" }, + { method: "get", path: ExperimentalPaths.session, name: "archived" }, + { method: "get", path: SessionPaths.list, name: "roots" }, +] satisfies Array<{ method: Method; path: string; name: string }> + +const queryParamPatterns = [ + { method: "get", path: SessionPaths.diff, name: "messageID", pattern: "^msg" }, +] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> + +const pathParamPatterns = [ + { method: "get", path: SessionPaths.get, name: "sessionID", pattern: "^ses" }, + { method: "get", path: SessionPaths.message, name: "messageID", pattern: "^msg" }, + { method: "patch", path: SessionPaths.updatePart, name: "partID", pattern: "^prt" }, + { method: "post", path: SessionPaths.permissions, name: "permissionID", pattern: "^per" }, + { method: "post", path: "/permission/:requestID/reply", name: "requestID", pattern: "^per" }, + { method: "post", path: "/question/:requestID/reply", name: "requestID", pattern: "^que" }, + { method: "put", path: PtyPaths.update, name: "ptyID", pattern: "^pty" }, + { method: "delete", path: WorkspacePaths.remove, name: "id", pattern: "^wrk" }, +] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> + +function app() { + return Server.Default().app +} + +function request(url: string, init?: RequestInit) { + return Effect.promise(async () => app().request(url, init)) +} + +function withTmp( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) +} + +function openApiPath(path: string) { + return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}") +} + +function queryParameters(operation: OpenApiOperation | undefined) { + return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name) +} + +function queryParameter(operation: OpenApiOperation | undefined, name: string) { + return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name) +} + +function pathParameter(operation: OpenApiOperation | undefined, name: string) { + return (operation?.parameters ?? []).find((param) => param.in === "path" && param.name === name) +} + +function assertAdvertisedQueryParamsAreRuntimeFields(input: { + readonly method: Method + readonly operation: OpenApiOperation | undefined + readonly path: string + readonly query: QuerySchema +}) { + const runtimeFields = new Set(Object.keys(input.query.fields)) + const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name)) + + expect( + advertisedOnly, + `${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`, + ).toEqual([]) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() +}) + +// Regression for the "OpenAPI advertises ?directory&workspace, runtime +// rejects them" drift class. Each affected route must accept both params +// without 400. +describe("httpapi query schema drift", () => { + const routingParams = (dir: string) => + `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}` + + const expectNotSchemaRejection = (status: number, url: string) => { + expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400) + } + + it.effect( + "boolean query schema accepts only true and false strings", + Effect.sync(() => { + const decode = Schema.decodeUnknownSync(QueryBoolean) + const encode = Schema.encodeUnknownSync(QueryBoolean) + + expect(decode("true")).toBe(true) + expect(decode("false")).toBe(false) + expect(encode(true)).toBe("true") + expect(encode(false)).toBe("false") + + for (const input of ["1", "yes", "True", "", true, false]) { + expect(() => decode(input)).toThrow() + } + }), + ) + + it.effect( + "OpenAPI query params are declared by runtime query schemas", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const route of openApiDriftRoutes) { + assertAdvertisedQueryParamsAreRuntimeFields({ + ...route, + operation: spec.paths[openApiPath(route.path)]?.[route.method], + }) + } + }), + ) + + it.effect( + "OpenAPI query and path schemas preserve compatibility metadata", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const expected of numericSdkQueryParams) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual(expected.schema) + } + for (const expected of booleanSdkQueryParams) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual(QueryBooleanOpenApi) + } + for (const expected of queryParamPatterns) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual({ type: "string", pattern: expected.pattern }) + } + for (const expected of pathParamPatterns) { + expect( + pathParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual({ type: "string", pattern: expected.pattern }) + } + }), + ) + + it.effect( + "drift assertion catches spec-only workspace query params", + Effect.sync(() => { + expect(() => + assertAdvertisedQueryParamsAreRuntimeFields({ + method: "get", + operation: { + parameters: [ + { name: "directory", in: "query" }, + { name: "workspace", in: "query" }, + ], + }, + path: "/fixture", + query: Schema.Struct({}), + }), + ).toThrow("advertises query params not accepted by runtime schema") + }), + ) + + it.live( + "session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "session messages accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/file accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find/file?query=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/text accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find?pattern=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file read accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/file?path=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental tool list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "vcs diff accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-reference.test.ts b/packages/opencode/test/server/httpapi-reference.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b187bd4eea2e0b9a1ae2772013b0dce34b2db038 --- /dev/null +++ b/packages/opencode/test/server/httpapi-reference.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test } from "bun:test" +import path from "path" +import { Server } from "../../src/server/server" +import { Global } from "@opencode-ai/core/global" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { Effect } from "effect" +import { pollWithTimeout } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("reference HttpApi", () => { + test("lists usable references resolved in the server workspace", async () => { + await using tmp = await tmpdir({ + config: { + formatter: false, + lsp: false, + references: { + docs: "./docs", + effect: { repository: "Effect-TS/effect", branch: "main" }, + bad: "not-a-repo", + }, + }, + }) + + const body = await Effect.runPromise( + pollWithTimeout( + Effect.promise(async () => { + const response = await Server.Default().app.request("/api/reference", { + headers: { "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(200) + const body = await response.json() + return body.data.length === 0 ? undefined : body + }), + "references were not loaded", + ), + ) + expect(body).toMatchObject({ location: { directory: tmp.path } }) + expect(body.data).toEqual([ + { + name: "docs", + path: path.join(tmp.path, "docs"), + source: { + type: "local", + path: path.join(tmp.path, "docs"), + }, + }, + { + name: "effect", + path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect@main"), + source: { + type: "git", + repository: "Effect-TS/effect", + branch: "main", + }, + }, + ]) + }) +}) diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ea6fd38e8462aa3d2e3648738e27a9dbacad2cf --- /dev/null +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer } from "effect" +import { HttpClientResponse } from "effect/unstable/http" +import { eq } from "drizzle-orm" +import { Database } from "@opencode-ai/core/database/database" + +import { Session } from "@/session/session" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" +import { MessageID, PartID } from "../../src/session/schema" +import { PartTable } from "@opencode-ai/core/session/sql" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) + +const text = (response: HttpClientResponse.HttpClientResponse) => response.text + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +const seedCorruptStepFinishPart = Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + // Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row + // that broke the user's session in the OMO/Windows bug. + const { db } = yield* Database.Service + yield* db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, // drizzle's .set() can't narrow the discriminated union + }) + .where(eq(PartTable.id, partID)) + .run() + .pipe(Effect.orDie) + return info.id +}) + +describe("schema-rejection wire shape", () => { + it.instance( + "Payload schema rejection returns NamedError-shaped JSON, not empty", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const res = yield* requestInDirectory(SyncPaths.history, test.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ aggregate: -1 }), + }) + const body = yield* text(res) + expect(res.status).toBe(400) + expect(res.headers["content-type"] ?? "").toContain("application/json") + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ + name: "BadRequest", + data: { kind: expect.stringMatching(/^(Body|Payload)$/) }, + }) + expect(parsed.data.message).toEqual(expect.any(String)) + expect(parsed.data.message.length).toBeGreaterThan(0) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "Query schema rejection returns NamedError-shaped JSON", + () => + Effect.gen(function* () { + const test = yield* TestInstance + // /find/file?limit=999999 violates the limit constraint check. + const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}` + const res = yield* requestInDirectory(url, test.directory) + const body = yield* text(res) + expect(res.status).toBe(400) + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "v2 query schema rejection returns InvalidRequestError JSON", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const res = yield* requestInDirectory("/api/session?limit=0", test.directory) + const parsed = JSON.parse(yield* text(res)) + expect(res.status).toBe(400) + expect(parsed).toMatchObject({ _tag: "InvalidRequestError", kind: "Query" }) + expect(parsed.message).toEqual(expect.any(String)) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "rejected request body never echoes back unbounded — message is capped", + // Defense against DoS-amplification + secret-echo: Effect's Issue formatter + // dumps the rejected `actual` verbatim. A multi-MB invalid array would + // become a multi-MB 400 response and log line. Cap kicks in around 1KB. + () => + Effect.gen(function* () { + const test = yield* TestInstance + const huge = "X".repeat(50_000) + const res = yield* requestInDirectory(SyncPaths.history, test.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ aggregate: huge }), + }) + const body = yield* text(res) + expect(res.status).toBe(400) + // 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB. + expect(body.length).toBeLessThan(2 * 1024) + const parsed = JSON.parse(body) + expect(parsed.data.message).not.toContain(huge) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = yield* seedCorruptStepFinishPart + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}` + const res = yield* requestInDirectory(url, test.directory) + const body = yield* text(res) + expect(res.status).toBe(400) + expect(res.headers["content-type"] ?? "").toContain("application/json") + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } }) + // Field path in data.message — what made this PR worth shipping. + expect(parsed.data.message).toMatch(/output/) + }), + { config: { formatter: false, lsp: false } }, + ) +}) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c81b3b771b2400ffa7191b67330f1e03a4fea01d --- /dev/null +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -0,0 +1,913 @@ +import { afterEach, describe, expect } from "bun:test" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { Deferred, Effect, Layer } from "effect" +import type * as Scope from "effect/Scope" +import { HttpServer } from "effect/unstable/http" +import { ChildProcessSpawner } from "effect/unstable/process" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Flag } from "@opencode-ai/core/flag/flag" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { validateSession } from "../../src/cli/tui/validate-session" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { MessageV2 } from "../../src/session/message-v2" + +import type { Config } from "@/config/config" +import { Session as SessionNs } from "@/session/session" +import { errorMessage } from "../../src/util/error" +import { TestLLMServer } from "../lib/llm-server" +import path from "path" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" +import { testProviderConfig } from "../lib/test-provider" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { Database } from "@opencode-ai/core/database/database" +import { httpApiLayer } from "./httpapi-layer" + +const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const appLayer = AppNodeBuilder.build( + LayerNode.group([FSUtil.node, CrossSpawnSpawner.node, InstanceStore.node, Database.node, SessionNs.node]), + [[InstanceStore.bootstrapNode, noopBootstrapLayer]], +) +const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer)) + +const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +type ServerPath = "default" | "raw" +type Sdk = ReturnType +type SdkResult = { response: Response; data?: unknown; error?: unknown } +type Captured = { status: number; data?: unknown; error?: unknown } +type ProjectFixture = { sdk: Sdk; directory: string } +type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] } +type TestServices = + | FSUtil.Service + | ChildProcessSpawner.ChildProcessSpawner + | InstanceStore.Service + | SessionNs.Service + | HttpServer.HttpServer +type TestScope = Scope.Scope | TestServices + +function client( + serverPath: ServerPath, + directory?: string, + input?: { + password?: string + username?: string + headers?: Record + workspaceID?: string + onRequest?: (request: Request) => void + }, +) { + return serverFetch(serverPath, input).pipe( + Effect.map((fetch) => + createOpencodeClient({ + baseUrl: "http://localhost", + directory, + experimental_workspaceID: input?.workspaceID, + headers: input?.headers, + fetch, + }), + ), + ) +} + +function serverFetch( + serverPath: ServerPath, + input?: { password?: string; username?: string; onRequest?: (request: Request) => void }, +) { + return HttpServer.HttpServer.use((server) => + Effect.sync(() => { + void serverPath + Flag.OPENCODE_SERVER_PASSWORD = input?.password + Flag.OPENCODE_SERVER_USERNAME = input?.username + const baseUrl = HttpServer.formatAddress(server.address) + return Object.assign( + async (request: RequestInfo | URL, init?: RequestInit) => { + const source = request instanceof Request ? request : new Request(request, init) + input?.onRequest?.(source) + const url = new URL(source.url) + return globalThis.fetch(new Request(new URL(`${url.pathname}${url.search}`, baseUrl), source)) + }, + { preconnect: globalThis.fetch.preconnect }, + ) satisfies typeof globalThis.fetch + }), + ) +} + +function authorization(username: string, password: string) { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +function call(request: () => Promise) { + return Effect.promise(request) +} + +function capture(request: () => Promise) { + return call(request).pipe( + Effect.map((result) => ({ + status: result.response.status, + data: result.data, + error: result.error, + })), + ) +} + +function captureThrown(request: () => Promise) { + return call(async () => { + try { + await request() + } catch (error) { + return error + } + }) +} + +function expectStatus(request: () => Promise<{ response: Response }>, status: number) { + return call(request).pipe( + Effect.tap((result) => Effect.sync(() => expect(result.response.status).toBe(status))), + Effect.asVoid, + ) +} + +function firstEvent(open: (signal: AbortSignal) => Promise<{ stream: AsyncIterator }>) { + return Effect.acquireRelease( + Effect.sync(() => new AbortController()), + (controller) => Effect.sync(() => controller.abort()), + ).pipe( + Effect.flatMap((controller) => + Effect.acquireRelease( + call(() => open(controller.signal)), + (events) => call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore), + ).pipe( + Effect.flatMap((events) => + call(() => events.stream.next()).pipe( + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => Effect.fail(new Error("timed out waiting for SDK event")), + }), + ), + ), + Effect.map((result) => result.value), + ), + ), + ) +} + +function record(value: unknown) { + return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {} +} + +function array(value: unknown) { + return Array.isArray(value) ? value : [] +} + +function statuses(input: Record) { + return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status])) +} + +function firstPartText(value: unknown) { + return record(array(record(value).parts)[0]).text +} + +function sessionTitles(value: unknown) { + return array(value) + .map((item) => record(item).title) + .filter((title): title is string => typeof title === "string") + .sort() +} + +function resetState() { + return Effect.promise(async () => { + await disposeAllInstances() + await resetDatabase() + }) +} + +function httpapi(name: string, effect: Effect.Effect) { + it.live(name, effect) +} + +function httpapiInstance( + name: string, + options: { + serverPath: ServerPath + git?: boolean + config?: Partial + setup?: (dir: string) => Effect.Effect + }, + run: (input: ProjectFixture) => Effect.Effect, +) { + it.instance( + name, + Effect.gen(function* () { + const instance = yield* TestInstance + yield* options.setup?.(instance.directory) ?? Effect.void + return yield* run({ sdk: yield* client(options.serverPath, instance.directory), directory: instance.directory }) + }), + { git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } }, + ) +} + +function serverPathParity(name: string, scenario: (serverPath: ServerPath) => Effect.Effect) { + it.live(name, scenario("raw")) +} + +function withProject( + serverPath: ServerPath, + options: { + git?: boolean + config?: Partial + setup?: (dir: string) => Effect.Effect + }, + run: (input: ProjectFixture) => Effect.Effect, +) { + return Effect.gen(function* () { + const directory = yield* tmpdirScoped({ + git: options.git ?? false, + config: { formatter: false, lsp: false, ...options.config }, + }) + yield* options.setup?.(directory) ?? Effect.void + return yield* run({ sdk: yield* client(serverPath, directory), directory }) + }) +} + +function withStandardProject( + serverPath: ServerPath, + run: (input: ProjectFixture) => Effect.Effect, +) { + return withProject(serverPath, { setup: writeStandardFiles }, run) +} + +function withFakeLlm(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect) { + return Effect.gen(function* () { + const llm = yield* TestLLMServer + return yield* withProject(serverPath, { config: testProviderConfig(llm.url) }, (input) => run({ ...input, llm })) + }).pipe(Effect.provide(TestLLMServer.layer)) +} + +function withFakeLlmProject( + serverPath: ServerPath, + options: { setup?: (dir: string) => Effect.Effect }, + run: (input: LlmProjectFixture) => Effect.Effect, +) { + return Effect.gen(function* () { + const llm = yield* TestLLMServer + return yield* withProject( + serverPath, + { + config: testProviderConfig(llm.url), + setup: options.setup, + }, + (input) => run({ ...input, llm }), + ) + }).pipe(Effect.provide(TestLLMServer.layer)) +} + +function writeStandardFiles(dir: string) { + return FSUtil.Service.use((fs) => + Effect.all([ + fs.writeWithDirs(path.join(dir, "hello.txt"), "hello"), + fs.writeWithDirs(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n"), + ]).pipe(Effect.asVoid), + ) +} + +function writeProjectSkill(dir: string) { + return FSUtil.Service.use((fs) => + fs.writeWithDirs( + path.join(dir, ".opencode", "skills", "project-rest-skill", "SKILL.md"), + `--- +name: project-rest-skill +description: A project skill visible to REST API prompts. +--- + +# Project REST Skill +`, + ), + ) +} + +function seedMessage(directory: string, sessionID: string) { + const id = SessionID.make(sessionID) + return InstanceStore.Service.use((store) => + store.provide( + { directory }, + SessionNs.Service.use((svc) => + Effect.gen(function* () { + const message = yield* svc.updateMessage({ + id: MessageID.ascending(), + sessionID: id, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, + tools: {}, + } satisfies SessionV1.User) + const part = yield* svc.updatePart({ + id: PartID.ascending(), + sessionID: id, + messageID: message.id, + type: "text", + text: "seeded message", + }) + return { message, part } + }), + ), + ), + ) +} + +afterEach(async () => { + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + await disposeAllInstances() + await resetDatabase() +}) + +describe("HttpApi SDK", () => { + httpapi( + "uses the generated SDK for global and control routes", + Effect.gen(function* () { + const sdk = yield* client("raw") + const health = yield* call(() => sdk.global.health()) + const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" })) + + expect(health.response.status).toBe(200) + expect(health.data).toMatchObject({ healthy: true }) + expect(yield* firstEvent((signal) => sdk.global.event({ signal }))).toMatchObject({ + payload: { type: "server.connected" }, + }) + expect(log.response.status).toBe(200) + expect(log.data).toBe(true) + yield* expectStatus(() => sdk.auth.set({ providerID: "test" }), 400) + }), + ) + + httpapiInstance( + "uses the generated SDK for safe instance routes", + { serverPath: "raw", git: false, setup: writeStandardFiles }, + ({ sdk }) => + Effect.gen(function* () { + const file = yield* call(() => sdk.file.read({ path: "hello.txt" })) + const session = yield* call(() => sdk.session.create({ title: "sdk" })) + const listed = yield* call(() => sdk.session.list({ roots: true, limit: 10 })) + + expect(file.response.status).toBe(200) + expect(file.data).toMatchObject({ content: "hello" }) + expect(session.response.status).toBe(200) + expect(session.data).toMatchObject({ title: "sdk" }) + expect(listed.response.status).toBe(200) + expect(listed.data?.map((item) => item.id)).toContain(session.data?.id) + + yield* Effect.all([ + expectStatus(() => sdk.project.current(), 200), + expectStatus(() => sdk.config.get(), 200), + expectStatus(() => sdk.config.providers(), 200), + expectStatus(() => sdk.find.files({ query: "hello", limit: 10 }), 200), + ]) + }), + ) + + httpapi( + "routes configured SDK directory and workspace for v2 location GETs", + withProject("raw", { setup: writeStandardFiles }, ({ directory }) => + Effect.gen(function* () { + const workspaceID = "wrk_sdk" + let request: Request | undefined + const sdk = yield* client("raw", directory, { + workspaceID, + onRequest: (value) => (request = value), + }) + const found = yield* pollWithTimeout( + call(() => sdk.v2.fs.find({ query: "hello", type: "file" })).pipe( + Effect.map((result) => (result.data?.data.length ? result : undefined)), + ), + "SDK file search index was not ready", + ) + const url = new URL(request!.url) + + expect(found.response.status).toBe(200) + expect(found.data).toMatchObject({ data: [{ path: "hello.txt", type: "file" }] }) + expect(url.searchParams.get("directory")).toBe(directory) + expect(url.searchParams.get("workspace")).toBe(workspaceID) + expect(url.searchParams.get("location[directory]")).toBe(directory) + expect(url.searchParams.get("location[workspace]")).toBe(workspaceID) + expect(request!.headers.has("x-opencode-directory")).toBe(false) + expect(request!.headers.has("x-opencode-workspace")).toBe(false) + }), + ), + ) + + serverPathParity("matches generated SDK global and control behavior", (serverPath) => + Effect.gen(function* () { + const sdk = yield* client(serverPath) + const health = yield* capture(() => sdk.global.health()) + const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" })) + const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" })) + + return { + statuses: statuses({ health, log, invalidAuth }), + health: record(health.data).healthy, + log: log.data, + } + }), + ) + + serverPathParity("matches generated SDK global event stream", (serverPath) => + Effect.gen(function* () { + const sdk = yield* client(serverPath) + const event = yield* firstEvent((signal) => sdk.global.event({ signal })) + return { type: record(record(event).payload).type } + }), + ) + + serverPathParity("matches generated SDK instance event stream", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => + firstEvent((signal) => sdk.event.subscribe(undefined, { signal })).pipe( + Effect.map((event) => ({ type: record(record(event).payload).type })), + ), + ), + ) + + serverPathParity("matches generated SDK missing session errors", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => + Effect.gen(function* () { + const sessionID = "ses_missing" + const expected = { + name: "NotFoundError", + data: { message: `Session not found: ${sessionID}` }, + } + const missing = yield* capture(() => sdk.session.get({ sessionID })) + const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true })) + + // Result-tuple path: error body is preserved as-is so existing + // consumers reading `result.error.name` / `JSON.stringify(error)` + // keep working byte-for-byte. + expect(missing.error).toEqual(expected) + // throwOnError path: SDK wraps the body in a real Error with the + // server's message, with the original parsed body preserved under + // `.cause.body`. + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toBe(expected.data.message) + expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected) + return { + status: missing.status, + error: missing.error, + thrown, + } + }), + ), + ) + + serverPathParity("formats missing session validation errors for -s", (serverPath) => + withStandardProject(serverPath, ({ directory }) => + Effect.gen(function* () { + const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T" + const fetch = yield* serverFetch(serverPath) + const thrown = yield* captureThrown(() => + validateSession({ + url: "http://localhost", + directory, + sessionID, + fetch, + }), + ) + expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`) + return errorMessage(thrown) + }), + ), + ) + + httpapiInstance( + "uses generated SDK basic auth behavior", + { serverPath: "raw", setup: writeStandardFiles }, + ({ directory }) => + Effect.gen(function* () { + const missingSdk = yield* client("raw", directory, { password: "secret" }) + const missing = yield* capture(() => missingSdk.file.read({ path: "hello.txt" })) + const badSdk = yield* client("raw", directory, { + password: "secret", + headers: { authorization: authorization("opencode", "wrong") }, + }) + const bad = yield* capture(() => badSdk.file.read({ path: "hello.txt" })) + const goodSdk = yield* client("raw", directory, { + password: "secret", + headers: { authorization: authorization("opencode", "secret") }, + }) + const good = yield* capture(() => goodSdk.file.read({ path: "hello.txt" })) + + return { + statuses: statuses({ missing, bad, good }), + content: record(good.data).content, + } + }), + ) + + serverPathParity("matches generated SDK instance read routes", (serverPath) => + withProject(serverPath, { git: true, setup: writeStandardFiles }, ({ sdk, directory }) => + Effect.gen(function* () { + const project = yield* capture(() => sdk.project.current()) + const projects = yield* capture(() => sdk.project.list()) + const paths = yield* capture(() => sdk.path.get()) + const config = yield* capture(() => sdk.config.get()) + const providers = yield* capture(() => sdk.config.providers()) + const file = yield* capture(() => sdk.file.read({ path: "hello.txt" })) + const files = yield* capture(() => sdk.file.list({ path: "." })) + const fileStatus = yield* capture(() => sdk.file.status()) + const findFiles = yield* capture(() => sdk.find.files({ query: "hello", limit: 10 })) + const findText = yield* capture(() => sdk.find.text({ pattern: "sdk-parity" })) + const agents = yield* capture(() => sdk.app.agents()) + const skills = yield* capture(() => sdk.app.skills()) + const tools = yield* capture(() => sdk.tool.ids()) + const vcs = yield* capture(() => sdk.vcs.get()) + const formatter = yield* capture(() => sdk.formatter.status()) + const lsp = yield* capture(() => sdk.lsp.status()) + + return { + statuses: statuses({ + project, + projects, + paths, + config, + providers, + file, + files, + fileStatus, + findFiles, + findText, + agents, + skills, + tools, + vcs, + formatter, + lsp, + }), + project: { worktreeSelected: record(project.data).worktree === directory }, + paths: { directorySelected: record(paths.data).directory === directory }, + file: record(file.data).content, + hasProject: array(projects.data).length > 0, + foundFile: JSON.stringify(findFiles.data).includes("hello.txt"), + foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"), + listedFile: JSON.stringify(files.data).includes("hello.txt"), + vcs: { hasBranch: typeof record(vcs.data).branch === "string" }, + } + }), + ), + ) + + serverPathParity("matches generated SDK session lifecycle routes", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => + Effect.gen(function* () { + const parent = yield* capture(() => sdk.session.create({ title: "parent" })) + const parentID = String(record(parent.data).id) + const child = yield* capture(() => sdk.session.create({ title: "child", parentID })) + const childID = String(record(child.data).id) + const get = yield* capture(() => sdk.session.get({ sessionID: parentID })) + const update = yield* capture(() => sdk.session.update({ sessionID: parentID, title: "renamed" })) + const roots = yield* capture(() => sdk.session.list({ roots: true, limit: 10 })) + const all = yield* capture(() => sdk.session.list({ roots: false, limit: 10 })) + const children = yield* capture(() => sdk.session.children({ sessionID: parentID })) + const todo = yield* capture(() => sdk.session.todo({ sessionID: parentID })) + const status = yield* capture(() => sdk.session.status()) + const messages = yield* capture(() => sdk.session.messages({ sessionID: parentID })) + const missingGet = yield* capture(() => sdk.session.get({ sessionID: "ses_missing" })) + const missingMessages = yield* capture(() => sdk.session.messages({ sessionID: "ses_missing", limit: 2 })) + const invalidCursor = yield* capture(() => + sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" }), + ) + const deleted = yield* capture(() => sdk.session.delete({ sessionID: childID })) + const getDeleted = yield* capture(() => sdk.session.get({ sessionID: childID })) + + return { + statuses: statuses({ + parent, + child, + get, + update, + roots, + all, + children, + todo, + status, + messages, + missingGet, + missingMessages, + invalidCursor, + deleted, + getDeleted, + }), + getTitle: record(get.data).title, + updatedTitle: record(update.data).title, + rootTitles: sessionTitles(roots.data), + allTitles: sessionTitles(all.data), + childCount: array(children.data).length, + todoCount: array(todo.data).length, + messageCount: array(messages.data).length, + } + }), + ), + ) + + serverPathParity("matches generated SDK session message and part routes", (serverPath) => + withStandardProject(serverPath, ({ sdk, directory }) => + Effect.gen(function* () { + const session = yield* capture(() => sdk.session.create({ title: "messages" })) + const sessionID = String(record(session.data).id) + const seeded = yield* seedMessage(directory, sessionID) + const list = yield* capture(() => sdk.session.messages({ sessionID })) + const page = yield* capture(() => sdk.session.messages({ sessionID, limit: 1 })) + const message = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id })) + const partUpdate = yield* capture(() => + sdk.part.update({ + sessionID, + messageID: seeded.message.id, + partID: seeded.part.id, + part: { ...seeded.part, text: "updated message" } as NonNullable< + Parameters[0]["part"] + >, + }), + ) + const updated = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id })) + const partDelete = yield* capture(() => + sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }), + ) + const withoutPart = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id })) + const deleteMessage = yield* capture(() => + sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id }), + ) + const missingMessage = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id })) + + return { + statuses: statuses({ + session, + list, + page, + message, + partUpdate, + updated, + partDelete, + withoutPart, + deleteMessage, + missingMessage, + }), + listCount: array(list.data).length, + pageCount: array(page.data).length, + initialText: firstPartText(message.data), + updatedText: firstPartText(updated.data), + partCountAfterDelete: array(record(withoutPart.data).parts).length, + } + }), + ), + ) + + // Regression: EventV2 must publish on the same ProjectBus the /event handler + // subscribes to, AND the /event stream must forward handler ALS/context into the + // body-pump fiber. Drives the full SDK → /event → Session.updatePart → sync.run → + // bus.publish → SDK subscriber path. Goes red if either the publisher uses a + // different bus instance (Bug 2 / pre-#27825) or the stream loses context (Bug 1 / + // pre-#27425). + serverPathParity("streams sync-backed part updates to /event subscribers", (serverPath) => + withStandardProject(serverPath, ({ sdk, directory }) => + Effect.gen(function* () { + const session = yield* capture(() => sdk.session.create({ title: "sync-backed part event" })) + const sessionID = String(record(session.data).id) + const seeded = yield* seedMessage(directory, sessionID) + + const controller = new AbortController() + yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort())) + const events = yield* call(() => sdk.event.subscribe(undefined, { signal: controller.signal })) + yield* Effect.addFinalizer(() => + call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore), + ) + + const ready = yield* Deferred.make() + const received = yield* Deferred.make() + + yield* call(async () => { + for await (const event of events.stream) { + const payload = record(event).payload ?? event + const type = record(payload).type + if (type === "server.connected") { + Deferred.doneUnsafe(ready, Effect.void) + continue + } + if (type === MessageV2.Event.PartUpdated.type) { + Deferred.doneUnsafe(received, Effect.succeed(payload)) + return + } + } + }).pipe(Effect.forkScoped) + + yield* awaitWithTimeout(Deferred.await(ready), "timed out waiting for /event server.connected", "2 seconds") + + const updated = yield* capture(() => + sdk.part.update({ + sessionID, + messageID: seeded.message.id, + partID: seeded.part.id, + part: { ...seeded.part, text: "updated via sync" } as NonNullable< + Parameters[0]["part"] + >, + }), + ) + expect(updated.status).toBe(200) + + const event = yield* awaitWithTimeout( + Deferred.await(received), + "timed out waiting for message.part.updated bus payload over /event", + "5 seconds", + ) + const properties = record(record(event).properties) + expect(record(properties.part)).toMatchObject({ id: seeded.part.id, type: "text" }) + return { type: record(event).type, partType: record(properties.part).type } + }), + ), + ) + + serverPathParity("matches generated SDK prompt no-reply routes", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => + Effect.gen(function* () { + const session = yield* capture(() => sdk.session.create({ title: "prompt" })) + const sessionID = String(record(session.data).id) + const prompt = yield* capture(() => + sdk.session.prompt({ + sessionID, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }), + ) + const asyncPrompt = yield* capture(() => + sdk.session.promptAsync({ + sessionID, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "async hello" }], + }), + ) + const messages = yield* capture(() => sdk.session.messages({ sessionID })) + + return { + statuses: statuses({ session, prompt, asyncPrompt, messages }), + promptRole: record(record(prompt.data).info).role, + messageCount: array(messages.data).length, + messageTexts: array(messages.data) + .flatMap((item) => array(record(item).parts)) + .map((part) => record(part).text) + .filter((text): text is string => typeof text === "string") + .sort(), + } + }), + ), + ) + + serverPathParity("matches generated SDK prompt streaming through fake LLM", (serverPath) => + withFakeLlm(serverPath, ({ sdk, llm }) => + Effect.gen(function* () { + yield* llm.text("fake world", { usage: { input: 11, output: 7 } }) + const session = yield* capture(() => + sdk.session.create({ + title: "llm prompt", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }), + ) + const sessionID = String(record(session.data).id) + const prompt = yield* capture(() => + sdk.session.prompt({ + sessionID, + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello llm" }], + }), + ) + const messages = yield* capture(() => sdk.session.messages({ sessionID })) + const inputs = yield* llm.inputs + + return { + statuses: statuses({ session, prompt, messages }), + calls: inputs.length, + requestedModel: inputs[0]?.model, + responseText: JSON.stringify(prompt.data).includes("fake world"), + persistedText: JSON.stringify(messages.data).includes("fake world"), + userText: JSON.stringify(messages.data).includes("hello llm"), + } + }), + ), + ) + + httpapi( + "includes project skills in REST API prompt context", + withFakeLlmProject("default", { setup: writeProjectSkill }, ({ sdk, llm }) => + Effect.gen(function* () { + yield* llm.text("skill context ok", { usage: { input: 11, output: 7 } }) + const session = yield* capture(() => + sdk.session.create({ + title: "project skill prompt", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }), + ) + const sessionID = String(record(session.data).id) + const prompt = yield* capture(() => + sdk.session.prompt({ + sessionID, + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello skill context" }], + }), + ) + const inputs = yield* llm.inputs + + expect(session.status).toBe(200) + expect(prompt.status).toBe(200) + expect(JSON.stringify(inputs[0])).toContain("project-rest-skill") + }), + ), + ) + + serverPathParity("matches generated SDK TUI validation and command routes", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => + Effect.gen(function* () { + const session = yield* capture(() => sdk.session.create({ title: "tui" })) + const sessionID = String(record(session.data).id) + const appendPrompt = yield* capture(() => sdk.tui.appendPrompt({ text: "hello" })) + const openHelp = yield* capture(() => sdk.tui.openHelp()) + const openSessions = yield* capture(() => sdk.tui.openSessions()) + const openThemes = yield* capture(() => sdk.tui.openThemes()) + const openModels = yield* capture(() => sdk.tui.openModels()) + const submitPrompt = yield* capture(() => sdk.tui.submitPrompt()) + const clearPrompt = yield* capture(() => sdk.tui.clearPrompt()) + const executeCommand = yield* capture(() => sdk.tui.executeCommand({ command: "session_new" })) + const showToast = yield* capture(() => sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" })) + const selectSession = yield* capture(() => sdk.tui.selectSession({ sessionID })) + const missingSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "ses_missing" })) + const invalidSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "invalid_session_id" })) + + return { + statuses: statuses({ + session, + appendPrompt, + openHelp, + openSessions, + openThemes, + openModels, + submitPrompt, + clearPrompt, + executeCommand, + showToast, + selectSession, + missingSession, + invalidSession, + }), + data: { + appendPrompt: appendPrompt.data, + openHelp: openHelp.data, + openSessions: openSessions.data, + openThemes: openThemes.data, + openModels: openModels.data, + submitPrompt: submitPrompt.data, + clearPrompt: clearPrompt.data, + executeCommand: executeCommand.data, + showToast: showToast.data, + selectSession: selectSession.data, + }, + } + }), + ), + ) + + serverPathParity("matches generated SDK project git initialization", (serverPath) => + withProject(serverPath, {}, ({ sdk, directory }) => + Effect.gen(function* () { + const before = yield* capture(() => sdk.project.current()) + const init = yield* capture(() => sdk.project.initGit()) + const after = yield* capture(() => sdk.project.current()) + + return { + statuses: statuses({ before, init, after }), + before: { + vcs: record(before.data).vcs ?? null, + worktree: record(before.data).worktree, + }, + init: { + vcs: record(init.data).vcs, + worktreeSelected: record(init.data).worktree === directory, + }, + after: { + vcs: record(after.data).vcs, + worktreeSelected: record(after.data).worktree === directory, + }, + } + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d7643cb330906e2e49f4389c0d975cbc6ef94b9 --- /dev/null +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -0,0 +1,1090 @@ +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { afterEach, describe, expect } from "bun:test" +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { Cause, Config, Effect, Exit, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http" +import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" + +import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service" +import { InstanceStore } from "../../src/project/instance-store" +import { Project } from "../../src/project/project" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import * as HttpSessionError from "../../src/server/routes/instance/httpapi/handlers/session-errors" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema" +import { Database } from "@opencode-ai/core/database/database" +import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import * as DateTime from "effect/DateTime" +import { eq } from "drizzle-orm" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { TestLLMServer } from "../lib/llm-server" +import { testProviderConfig } from "../lib/test-provider" +import { pollWithTimeout, testEffect } from "../lib/effect" + +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +const noopBootstrapLayer = Layer.succeed( + InstanceBootstrapService.Service, + InstanceBootstrapService.Service.of({ run: Effect.void }), +) +const appLayer = AppNodeBuilder.build( + LayerNode.group([InstanceStore.node, Project.node, Session.node, Workspace.node, Database.node, Ripgrep.node]), + [[InstanceStore.bootstrapNode, noopBootstrapLayer]], +) +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { + disableListenLog: true, + disableLogger: true, + }, +) +const httpApiLayer = servedRoutes.pipe( + Layer.provide(layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), +) +const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer)) + +function pathFor(path: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) +} + +function createSession(input?: Session.CreateInput) { + return Session.use.create(input) +} + +function createTextMessage(sessionID: SessionIDType, text: string) { + return Effect.gen(function* () { + const svc = yield* Session.Service + const info = yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, + time: { created: Date.now() }, + }) + const part = yield* svc.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text, + }) + return { info, part } + }) +} + +const localAdapter = (directory: string): WorkspaceAdapter => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => + Effect.acquireRelease( + Effect.gen(function* () { + registerAdapter(input.projectID, input.type, localAdapter(input.directory)) + return yield* Workspace.Service.use((svc) => + svc.create({ + type: input.type, + branch: null, + extra: null, + projectID: input.projectID, + }), + ) + }), + (info) => Workspace.use.remove(info.id).pipe(Effect.ignore), + ) + +const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time = seq) => + Effect.gen(function* () { + const message = SessionMessage.Assistant.make({ + id: SessionMessage.ID.create(), + type: "assistant", + agent: "build", + model: { + id: ModelV2.ID.make("model"), + providerID: ProviderV2.ID.make("provider"), + variant: ModelV2.VariantID.make("default"), + }, + time: { created: DateTime.makeUnsafe(time) }, + content: [], + }) + const { db } = yield* Database.Service + yield* db + .insert(SessionMessageTable) + .values([ + { + id: message.id, + session_id: sessionID, + type: message.type, + seq, + time_created: time, + data: { + time: { created: time }, + agent: message.agent, + model: message.model, + content: message.content, + } as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>, + }, + ]) + .run() + .pipe(Effect.orDie) + return message + }) + +const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(SessionMessageTable) + .values([ + { + id: SessionMessage.ID.create(), + session_id: sessionID, + type: "assistant", + seq: time, + time_created: time, + data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>, + }, + ]) + .run() + .pipe(Effect.orDie) + }) + +const setLegacySummaryDiff = (sessionID: SessionIDType) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ + summary_additions: 1, + summary_deletions: 0, + summary_files: 1, + summary_diffs: [{ additions: 1, deletions: 0 }], + }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) + }) + +const getWorkspaceID = (sessionID: SessionIDType) => + Effect.gen(function* () { + const { db } = yield* Database.Service + return yield* db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + }) + +const clearSessionPath = (sessionID: SessionIDType) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie) + }) + +function request(path: string, init?: RequestInit) { + const url = new URL(path, "http://localhost") + return HttpClientRequest.fromWeb(new Request(url, init)).pipe( + HttpClientRequest.setUrl(url.pathname), + HttpClient.execute, + ) +} + +function json(response: HttpClientResponse.HttpClientResponse) { + if (response.status !== 200) return response.text.pipe(Effect.flatMap((text) => Effect.die(new Error(text)))) + return response.json.pipe(Effect.map((value) => value as T)) +} + +function responseJson(response: HttpClientResponse.HttpClientResponse) { + return response.json +} + +function requestJson(path: string, init?: RequestInit) { + return request(path, init).pipe(Effect.flatMap(json)) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() +}) + +describe("session HttpApi", () => { + it.effect("maps busy sessions to public session busy errors", () => + Effect.gen(function* () { + const sessionID = SessionID.descending() + const exit = yield* HttpSessionError.mapBusy(Effect.fail(new Session.BusyError({ sessionID }))).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toMatchObject({ + _tag: "SessionBusyError", + sessionID, + message: `Session is busy: ${sessionID}`, + }) + } + }), + ) + + it.instance( + "returns declared not found errors for read routes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory } + const missingSession = SessionID.descending() + const missingSessionBody = { + name: "NotFoundError", + data: { message: `Session not found: ${missingSession}` }, + } + + const get = yield* request(pathFor(SessionPaths.get, { sessionID: missingSession }), { headers }) + expect(get.status).toBe(404) + expect(yield* responseJson(get)).toEqual(missingSessionBody) + + const children = yield* request(pathFor(SessionPaths.children, { sessionID: missingSession }), { headers }) + expect(children.status).toBe(404) + expect(yield* responseJson(children)).toEqual(missingSessionBody) + + const todo = yield* request(pathFor(SessionPaths.todo, { sessionID: missingSession }), { headers }) + expect(todo.status).toBe(404) + expect(yield* responseJson(todo)).toEqual(missingSessionBody) + + const messages = yield* request(pathFor(SessionPaths.messages, { sessionID: missingSession }), { headers }) + expect(messages.status).toBe(404) + expect(yield* responseJson(messages)).toEqual(missingSessionBody) + + const remove = yield* request(pathFor(SessionPaths.remove, { sessionID: missingSession }), { + headers, + method: "DELETE", + }) + expect(remove.status).toBe(404) + expect(yield* responseJson(remove)).toEqual(missingSessionBody) + + const prompt = yield* request(pathFor(SessionPaths.prompt, { sessionID: missingSession }), { + headers: { ...headers, "content-type": "application/json" }, + method: "POST", + body: JSON.stringify({ agent: "build", noReply: true, parts: [{ type: "text", text: "hello" }] }), + }) + expect(prompt.status).toBe(404) + expect(yield* responseJson(prompt)).toEqual(missingSessionBody) + + const abort = yield* request(pathFor(SessionPaths.abort, { sessionID: missingSession }), { + headers, + method: "POST", + }) + expect(abort.status).toBe(200) + expect(yield* responseJson(abort)).toBe(true) + + const session = yield* createSession({ title: "missing message" }) + const missingMessage = MessageID.ascending() + const message = yield* request( + pathFor(SessionPaths.message, { sessionID: session.id, messageID: missingMessage }), + { headers }, + ) + expect(message.status).toBe(404) + expect(yield* responseJson(message)).toEqual({ + name: "NotFoundError", + data: { message: `Message not found: ${missingMessage}` }, + }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "serves read routes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory } + const parent = yield* createSession({ title: "parent" }) + const child = yield* createSession({ title: "child", parentID: parent.id }) + const message = yield* createTextMessage(parent.id, "hello") + yield* createTextMessage(parent.id, "world") + + const listed = yield* requestJson(`${SessionPaths.list}?roots=true`, { headers }) + expect(listed.map((item) => item.id)).toContain(parent.id) + expect(Object.hasOwn(listed[0]!, "parentID")).toBe(false) + + expect(yield* requestJson>(SessionPaths.status, { headers })).toEqual({}) + + expect( + yield* requestJson(pathFor(SessionPaths.get, { sessionID: parent.id }), { headers }), + ).toMatchObject({ id: parent.id, title: "parent" }) + + expect( + (yield* requestJson(pathFor(SessionPaths.children, { sessionID: parent.id }), { + headers, + })).map((item) => item.id), + ).toEqual([child.id]) + + expect( + yield* requestJson(pathFor(SessionPaths.todo, { sessionID: parent.id }), { headers }), + ).toEqual([]) + + expect( + yield* requestJson(pathFor(SessionPaths.diff, { sessionID: parent.id }), { headers }), + ).toEqual([]) + + const messages = yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1`, { + headers, + }) + const messagePage = yield* json(messages) + const nextCursor = messages.headers["x-next-cursor"] + expect(nextCursor).toBeTruthy() + expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" }) + + expect( + (yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?before=${nextCursor}`, { + headers, + })).status, + ).toBe(400) + expect( + (yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1&before=invalid`, { + headers, + })).status, + ).toBe(400) + + expect( + yield* requestJson( + pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }), + { headers }, + ), + ).toMatchObject({ info: { id: message.info.id } }) + + yield* insertLegacyAssistantMessage(parent.id) + + expect( + (yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { + headers, + })).data, + ).toMatchObject([{ type: "assistant" }]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.live("uses the persisted session directory for prompt requests", () => + Effect.gen(function* () { + const llm = yield* TestLLMServer + yield* llm.text("ok", { usage: { input: 1, output: 1 } }) + + const config = testProviderConfig(llm.url) + const sessionDirectory = yield* tmpdirScoped({ git: true, config }) + const requestDirectory = yield* tmpdirScoped({ git: true, config }) + const session = yield* createSession({ title: "directory regression" }).pipe( + provideInstanceEffect(sessionDirectory), + ) + + const response = yield* request( + `${pathFor(SessionPaths.prompt, { sessionID: session.id })}?directory=${encodeURIComponent(requestDirectory)}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "which directory?" }], + }), + }, + ) + + expect(response.status).toBe(200) + yield* responseJson(response) + + const messages = yield* Session.use + .messages({ sessionID: session.id }) + .pipe(provideInstanceEffect(sessionDirectory), Effect.orDie) + const assistant = messages.find((message) => message.info.role === "assistant") + expect(assistant?.info.role === "assistant" ? assistant.info.path : undefined).toEqual({ + cwd: sessionDirectory, + root: sessionDirectory, + }) + }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), + ) + + it.instance( + "returns v2 public request errors for cursor and workspace query failures", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory } + const session = yield* createSession({ title: "v2 cursor" }) + const firstMessage = yield* insertLegacyAssistantMessage(session.id, 1, 2) + const secondMessage = yield* insertLegacyAssistantMessage(session.id, 2, 1) + + const sessionPage = yield* request( + `/api/session?${new URLSearchParams({ + limit: "1", + order: "asc", + directory: test.directory, + search: "v2", + })}`, + { headers }, + ) + const sessionCursor = (yield* json<{ data: Session.Info[]; cursor: { next?: string } }>(sessionPage)).cursor + .next + expect(sessionCursor).toBeTruthy() + expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({ + order: "asc", + directory: test.directory, + search: "v2", + anchor: { id: session.id, direction: "next" }, + }) + + const sessionNextPage = yield* request(`/api/session?cursor=${sessionCursor}`, { headers }) + expect(sessionNextPage.status).toBe(200) + + const invalidSessionCursor = yield* request(`/api/session?cursor=invalid`, { headers }) + expect(invalidSessionCursor.status).toBe(400) + expect(yield* responseJson(invalidSessionCursor)).toMatchObject({ + _tag: "InvalidCursorError", + message: "Invalid cursor", + }) + + const invalidWorkspace = yield* request(`/api/session?workspace=bad`, { headers }) + expect(invalidWorkspace.status).toBe(400) + expect(yield* responseJson(invalidWorkspace)).toMatchObject({ + _tag: "InvalidRequestError", + kind: "Query", + }) + + const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers }) + const messageBody = yield* json<{ data: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage) + const messageCursor = messageBody.cursor.next + expect(messageCursor).toBeTruthy() + expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id]) + expect(JSON.parse(Buffer.from(messageCursor!, "base64url").toString("utf8"))).toEqual({ + id: secondMessage.id, + order: "desc", + direction: "next", + }) + + const nextMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${messageCursor}`, { + headers, + }) + expect( + (yield* json<{ data: SessionMessage.Message[] }>(nextMessagePage)).data.map((message) => message.id), + ).toEqual([firstMessage.id]) + + const legacyMessageCursor = Buffer.from( + JSON.stringify({ id: secondMessage.id, time: 1, order: "desc", direction: "next" }), + ).toString("base64url") + const legacyMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${legacyMessageCursor}`, { + headers, + }) + expect( + (yield* json<{ data: SessionMessage.Message[] }>(legacyMessagePage)).data.map((message) => message.id), + ).toEqual([firstMessage.id]) + + const messageCursorWithOrder = yield* request( + `/api/session/${session.id}/message?cursor=${messageCursor}&order=asc`, + { headers }, + ) + expect(messageCursorWithOrder.status).toBe(400) + expect(yield* responseJson(messageCursorWithOrder)).toMatchObject({ + _tag: "InvalidCursorError", + message: "Cursor cannot be combined with order", + }) + + const invalidMessageCursor = yield* request(`/api/session/${session.id}/message?cursor=invalid`, { headers }) + expect(invalidMessageCursor.status).toBe(400) + expect(yield* responseJson(invalidMessageCursor)).toMatchObject({ + _tag: "InvalidCursorError", + message: "Invalid cursor", + }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "returns v2 public not found errors for missing sessions", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory } + const missing = SessionID.descending() + const expected = { + _tag: "SessionNotFoundError", + sessionID: missing, + message: `Session not found: ${missing}`, + } + + const messages = yield* request(`/api/session/${missing}/message`, { headers }) + expect(messages.status).toBe(404) + expect(yield* responseJson(messages)).toEqual(expected) + + const context = yield* request(`/api/session/${missing}/context`, { headers }) + expect(context.status).toBe(404) + expect(yield* responseJson(context)).toEqual(expected) + + const compact = yield* request(`/api/session/${missing}/compact`, { method: "POST", headers }) + expect(compact.status).toBe(404) + expect(yield* responseJson(compact)).toEqual(expected) + + const wait = yield* request(`/api/session/${missing}/wait`, { method: "POST", headers }) + expect(wait.status).toBe(404) + expect(yield* responseJson(wait)).toEqual(expected) + + const prompt = yield* request(`/api/session/${missing}/prompt`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ prompt: { text: "hello" } }), + }) + expect(prompt.status).toBe(404) + expect(yield* responseJson(prompt)).toEqual(expected) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "durably records one v2 prompt for exact message-ID retries", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory } + const session = yield* createSession({ title: "v2 prompt recording" }) + + const recordPrompt = () => + request(`/api/session/${session.id}/prompt`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" }, resume: false }), + }) + const first = yield* recordPrompt() + const retried = yield* recordPrompt() + type PromptBody = { id: string; prompt: { text: string }; delivery: string; promotedSeq?: number } + const firstBody = yield* json<{ data: PromptBody }>(first) + const retriedBody = yield* json<{ data: PromptBody }>(retried) + expect(first.status).toBe(200) + expect(retried.status).toBe(200) + expect(retriedBody).toEqual(firstBody) + expect(firstBody).toMatchObject({ + data: { id: "msg_http_prompt", prompt: { text: "hello" }, delivery: "steer" }, + }) + + const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, { + headers, + }) + expect(messages.data).toHaveLength(0) + const admitted = yield* Database.Service.use(({ db }) => + db + .select() + .from(SessionInputTable) + .where(eq(SessionInputTable.id, SessionMessage.ID.make("msg_http_prompt"))) + .get() + .pipe(Effect.orDie), + ) + expect(admitted).toMatchObject({ + id: "msg_http_prompt", + session_id: session.id, + delivery: "steer", + promoted_seq: null, + }) + const conflict = yield* request(`/api/session/${session.id}/prompt`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "goodbye" } }), + }) + expect(conflict.status).toBe(409) + expect(yield* responseJson(conflict)).toEqual({ + _tag: "ConflictError", + message: "Prompt message ID conflicts with an existing durable record: msg_http_prompt", + resource: "msg_http_prompt", + }) + + const wakeID = SessionMessage.ID.make("msg_http_wake") + const wake = yield* request(`/api/session/${session.id}/prompt`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ id: wakeID, prompt: { text: "hello again" } }), + }) + expect(wake.status).toBe(200) + const message = yield* pollWithTimeout( + requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${session.id}/message`, { headers }).pipe( + Effect.map(({ data }) => data.find((message) => message.id === wakeID)), + ), + "V2 prompt was not promoted after wake", + "10 seconds", + ) + expect(message).toMatchObject({ id: wakeID, type: "user" }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "returns v2 public unavailable errors for unfinished session mutations", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory } + const session = yield* createSession({ title: "v2 unavailable" }) + + const compact = yield* request(`/api/session/${session.id}/compact`, { method: "POST", headers }) + expect(compact.status).toBe(503) + expect(yield* responseJson(compact)).toEqual({ + _tag: "ServiceUnavailableError", + message: "Session compact is not available yet", + service: "session.compact", + }) + + const wait = yield* request(`/api/session/${session.id}/wait`, { method: "POST", headers }) + expect(wait.status).toBe(503) + expect(yield* responseJson(wait)).toEqual({ + _tag: "ServiceUnavailableError", + message: "Session wait is not available yet", + service: "session.wait", + }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "returns safe v2 unknown errors for corrupt projected messages", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* createSession({ title: "v2 corrupt message" }) + yield* insertCorruptV2Message(session.id) + + const messages = yield* request(`/api/session/${session.id}/message`, { + headers: { "x-opencode-directory": test.directory }, + }) + const messagesBody = yield* responseJson(messages) + expect(messages.status).toBe(500) + expect(messagesBody).toMatchObject({ + _tag: "UnknownError", + message: "Unexpected server error. Check server logs for details.", + }) + expect((messagesBody as { ref?: unknown }).ref).toMatch(/^err_[0-9a-f-]{8}$/) + expect(JSON.stringify(messagesBody)).not.toContain("assistant") + + const context = yield* request(`/api/session/${session.id}/context`, { + headers: { "x-opencode-directory": test.directory }, + }) + const contextBody = yield* responseJson(context) + expect(context.status).toBe(500) + expect(contextBody).toMatchObject({ + _tag: "UnknownError", + message: "Unexpected server error. Check server logs for details.", + }) + expect((contextBody as { ref?: unknown }).ref).toMatch(/^err_[0-9a-f-]{8}$/) + expect(JSON.stringify(contextBody)).not.toContain("assistant") + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "serves sessions with migrated summary diffs missing file details", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* createSession({ title: "legacy diff" }) + yield* setLegacySummaryDiff(session.id) + + const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), { + headers: { "x-opencode-directory": test.directory }, + }) + + expect(response.status).toBe(200) + expect((yield* json(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "serves lifecycle mutation routes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" } + + const createdEmpty = yield* requestJson(SessionPaths.create, { + method: "POST", + headers, + }) + expect(createdEmpty.id).toBeTruthy() + + const created = yield* requestJson(SessionPaths.create, { + method: "POST", + headers, + body: JSON.stringify({ title: "created" }), + }) + expect(created.title).toBe("created") + + const updated = yield* requestJson(pathFor(SessionPaths.update, { sessionID: created.id }), { + method: "PATCH", + headers, + body: JSON.stringify({ title: "updated", time: { archived: 1 } }), + }) + expect(updated).toMatchObject({ id: created.id, title: "updated", time: { archived: 1 } }) + + const forked = yield* requestJson(pathFor(SessionPaths.fork, { sessionID: created.id }), { + method: "POST", + headers, + }) + expect(forked.id).not.toBe(created.id) + + const forkedWithoutContentType = yield* requestJson( + pathFor(SessionPaths.fork, { sessionID: created.id }), + { + method: "POST", + headers: { "x-opencode-directory": test.directory }, + }, + ) + expect(forkedWithoutContentType.id).not.toBe(created.id) + + const invalidFork = yield* request(pathFor(SessionPaths.fork, { sessionID: created.id }), { + method: "POST", + headers, + body: "{", + }) + expect(invalidFork.status).toBe(400) + + const forkedWhitespace = yield* requestJson( + pathFor(SessionPaths.fork, { sessionID: created.id }), + { + method: "POST", + headers, + body: " \n", + }, + ) + expect(forkedWhitespace.id).not.toBe(created.id) + + expect( + yield* requestJson(pathFor(SessionPaths.abort, { sessionID: created.id }), { + method: "POST", + headers, + }), + ).toBe(true) + + expect( + yield* requestJson(pathFor(SessionPaths.remove, { sessionID: created.id }), { + method: "DELETE", + headers, + }), + ).toBe(true) + }), + { git: true, config: { formatter: false, lsp: false, share: "disabled" } }, + ) + + it.instance( + "persists selected workspace id when creating a session", + () => + Effect.gen(function* () { + const test = yield* TestInstance + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const project = yield* Project.use.fromDirectory(test.directory) + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "session-create-workspace", + directory: path.join(test.directory, ".workspace-local"), + }) + + const created = yield* requestJson(`${SessionPaths.create}?workspace=${workspace.id}`, { + method: "POST", + headers: { "x-opencode-directory": test.directory, "content-type": "application/json" }, + body: JSON.stringify({ title: "workspace session" }), + }) + const messages = yield* request( + `${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`, + { + headers: { "x-opencode-directory": test.directory }, + }, + ) + + expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id }) + expect(messages.status).toBe(200) + expect(yield* getWorkspaceID(created.id)).toEqual({ workspaceID: workspace.id }) + }), + { git: true, config: { formatter: false, lsp: false, share: "disabled" } }, + ) + + it.instance( + "validates archived timestamp values", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" } + const session = yield* createSession({ title: "archived" }) + const body = JSON.stringify({ time: { archived: -1 } }) + + const response = yield* request(pathFor(SessionPaths.update, { sessionID: session.id }), { + method: "PATCH", + headers, + body, + }) + expect(response.status).toBe(200) + expect((yield* json(response)).time.archived).toBe(-1) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "uses project-scoped path and directory precedence", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const currentDir = path.join(test.directory, "packages", "opencode", "src") + yield* Effect.promise(() => mkdir(currentDir, { recursive: true })) + + const store = yield* InstanceStore.Service + const { pathSession, pathlessSession } = yield* store.provide( + { directory: currentDir }, + Effect.gen(function* () { + return { + pathSession: yield* createSession(), + pathlessSession: yield* createSession(), + } + }).pipe(Effect.provideService(TestInstance, { directory: currentDir })), + ) + yield* clearSessionPath(pathlessSession.id) + + const query = new URLSearchParams({ + scope: "project", + path: "packages/opencode/src", + directory: currentDir, + }) + const headers = { "x-opencode-directory": test.directory } + const sessions = (yield* json( + yield* request(`${SessionPaths.list}?${query}`, { headers }), + )).map((item) => item.id) + + expect(sessions).toContain(pathSession.id) + expect(sessions).not.toContain(pathlessSession.id) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "lists sessions created through an equivalent directory hint", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const hint = test.directory + path.sep + const headers = { "x-opencode-directory": hint, "content-type": "application/json" } + const created = yield* requestJson(SessionPaths.create, { + method: "POST", + headers, + body: JSON.stringify({ title: "hinted" }), + }) + + const query = new URLSearchParams({ directory: hint, roots: "true" }) + const listed = yield* requestJson(`${SessionPaths.list}?${query}`, { headers }) + expect(listed.map((item) => item.id)).toContain(created.id) + + const globalQuery = new URLSearchParams({ directory: hint }) + const global = yield* requestJson(`${ExperimentalPaths.session}?${globalQuery}`, { headers }) + expect(global.map((item) => item.id)).toContain(created.id) + }), + { git: true, config: { formatter: false, lsp: false, share: "disabled" } }, + ) + + it.instance( + "lists Windows sessions for equivalent directory spellings", + () => + Effect.gen(function* () { + if (process.platform !== "win32") return + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" } + const created = yield* requestJson(SessionPaths.create, { + method: "POST", + headers, + body: JSON.stringify({ title: "windows spelling" }), + }) + + const forwardSlashes = test.directory.replaceAll("\\", "/") + const lowercaseDrive = test.directory.replace(/^[A-Z]:/, (drive) => drive.toLowerCase()) + const trailingSeparator = `${test.directory}\\` + for (const spelling of [forwardSlashes, lowercaseDrive, trailingSeparator]) { + const query = new URLSearchParams({ directory: spelling, roots: "true" }) + const listed = yield* requestJson(`${SessionPaths.list}?${query}`, { headers }) + expect({ spelling, ids: listed.map((item) => item.id) }).toEqual({ spelling, ids: [created.id] }) + } + }), + { git: true, config: { formatter: false, lsp: false, share: "disabled" } }, + { timeout: 15000 }, + ) + + it.instance( + "lists Windows sessions created through the global worktree sentinel", + () => + Effect.gen(function* () { + if (process.platform !== "win32") return + const globalWorktreeSentinel = "/" + const headers = { "x-opencode-directory": globalWorktreeSentinel, "content-type": "application/json" } + const driveRootSession = yield* requestJson(SessionPaths.create, { + method: "POST", + headers, + body: JSON.stringify({ title: "created at drive root" }), + }) + expect(driveRootSession.directory).toMatch(/^[A-Za-z]:\\$/) + + const query = new URLSearchParams({ directory: globalWorktreeSentinel, roots: "true" }) + const listed = yield* requestJson(`${SessionPaths.list}?${query}`, { headers }) + expect(listed.map((item) => item.id)).toContain(driveRootSession.id) + }), + { git: true, config: { formatter: false, lsp: false, share: "disabled" } }, + { timeout: 15000 }, + ) + + it.instance( + "serves paginated message link headers", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory } + const session = yield* createSession({ title: "messages" }) + yield* createTextMessage(session.id, "first") + yield* createTextMessage(session.id, "second") + const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1` + + const response = yield* request(route, { headers }) + + expect(response.headers["x-next-cursor"]).toBeTruthy() + expect(response.headers["link"]).toContain("limit=1") + expect(response.headers["access-control-expose-headers"]?.toLowerCase()).toContain("x-next-cursor") + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "serves message mutation routes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" } + const session = yield* createSession({ title: "messages" }) + const first = yield* createTextMessage(session.id, "first") + const second = yield* createTextMessage(session.id, "second") + + const updated = yield* requestJson( + pathFor(SessionPaths.updatePart, { + sessionID: session.id, + messageID: first.info.id, + partID: first.part.id, + }), + { + method: "PATCH", + headers, + body: JSON.stringify({ ...first.part, text: "updated" }), + }, + ) + expect(updated).toMatchObject({ id: first.part.id, type: "text", text: "updated" }) + + expect( + yield* requestJson( + pathFor(SessionPaths.deletePart, { + sessionID: session.id, + messageID: first.info.id, + partID: first.part.id, + }), + { method: "DELETE", headers }, + ), + ).toBe(true) + + expect( + yield* requestJson( + pathFor(SessionPaths.deleteMessage, { sessionID: session.id, messageID: second.info.id }), + { method: "DELETE", headers }, + ), + ).toBe(true) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "rejects part updates whose path and body ids disagree", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" } + const session = yield* createSession({ title: "part mismatch" }) + const message = yield* createTextMessage(session.id, "first") + const response = yield* request( + pathFor(SessionPaths.updatePart, { + sessionID: session.id, + messageID: message.info.id, + partID: message.part.id, + }), + { + method: "PATCH", + headers, + body: JSON.stringify({ ...message.part, id: PartID.ascending() }), + }, + ) + + expect(response.status).toBe(400) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "serves remaining non-LLM session mutation routes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" } + const session = yield* createSession({ title: "remaining" }) + + expect( + yield* requestJson(pathFor(SessionPaths.revert, { sessionID: session.id }), { + method: "POST", + headers, + body: JSON.stringify({ messageID: MessageID.ascending() }), + }), + ).toMatchObject({ id: session.id }) + + expect( + yield* requestJson(pathFor(SessionPaths.unrevert, { sessionID: session.id }), { + method: "POST", + headers, + }), + ).toMatchObject({ id: session.id }) + + const permissionID = String(PermissionV1.ID.ascending()) + const permission = yield* request( + pathFor(SessionPaths.permissions, { + sessionID: session.id, + permissionID, + }), + { + method: "POST", + headers, + body: JSON.stringify({ response: "once" }), + }, + ) + expect(permission.status).toBe(404) + expect(yield* responseJson(permission)).toEqual({ + _tag: "PermissionNotFoundError", + requestID: permissionID, + message: `Permission request not found: ${permissionID}`, + }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) +}) diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0577e6bf02d3020c444f89565a4a8048f1c09e09 --- /dev/null +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, mock } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Context, Effect, Layer } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { Session } from "@/session/session" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +const context = Context.empty() as Context.Context +const it = testEffect(Layer.mergeAll(LayerNode.compile(Session.node), httpApiLayer)) + +afterEach(async () => { + mock.restore() + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() +}) + +describe("sync HttpApi", () => { + it.instance( + "serves sync routes", + () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const tmp = yield* TestInstance + const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" } + const session = yield* Session.use.create({ title: "sync" }) + + const started = yield* requestInDirectory(SyncPaths.start, tmp.directory, { method: "POST", headers }) + expect(started.status).toBe(200) + expect(yield* started.json).toBe(true) + + const history = yield* requestInDirectory(SyncPaths.history, tmp.directory, { + method: "POST", + headers, + body: JSON.stringify({}), + }) + expect(history.status).toBe(200) + const rows = (yield* history.json) as Array<{ + id: string + aggregate_id: string + seq: number + type: string + data: Record + }> + expect(rows.map((row) => row.aggregate_id)).toContain(session.id) + + const replayed = yield* requestInDirectory(SyncPaths.replay, tmp.directory, { + method: "POST", + headers, + body: JSON.stringify({ + directory: tmp.directory, + events: rows + .filter((row) => row.aggregate_id === session.id) + .map((row) => ({ + id: row.id, + aggregateID: row.aggregate_id, + seq: row.seq, + type: row.type, + data: row.data, + })), + }), + }) + expect(replayed.status).toBe(200) + expect(yield* replayed.json).toEqual({ sessionID: session.id }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "validates seq values", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" } + const cases = [ + { + path: SyncPaths.history, + body: { aggregate: -1 }, + }, + { + path: SyncPaths.history, + body: { aggregate: 1.5 }, + }, + { + path: SyncPaths.replay, + body: { + directory: tmp.directory, + events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }], + }, + }, + { + path: SyncPaths.replay, + body: { + directory: tmp.directory, + events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }], + }, + }, + { + path: SyncPaths.replay, + body: { + directory: tmp.directory, + events: [{ id: "event", aggregateID: "session", seq: 0, type: "session.created", data: {} }], + }, + }, + ] + + for (const item of cases) { + const response = yield* requestInDirectory(item.path, tmp.directory, { + method: "POST", + headers, + body: JSON.stringify(item.body), + }) + expect(response.status).toBe(400) + } + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance.skip( + "returns structured validation errors", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const response = yield* Effect.promise(() => + HttpApiApp.webHandler().handler( + new Request(`http://localhost${SyncPaths.history}`, { + method: "POST", + headers: { "x-opencode-directory": tmp.directory, "content-type": "application/json" }, + body: JSON.stringify({ aggregate: -1 }), + }), + context, + ), + ) + + expect(response.status).toBe(400) + expect(response.headers.get("content-type") ?? "").toContain("application/json") + const body = (yield* Effect.promise(() => response.json())) as Record + expect(body.success).toBe(false) + expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) +}) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a7ccc60dab28f70143d1c6fc9c83f8346218bf6 --- /dev/null +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -0,0 +1,457 @@ +import { createHash } from "node:crypto" +import { describe, expect } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ConfigProvider, Effect, Layer, Option } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { + HttpClient, + HttpClientRequest, + HttpClientResponse, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { RuntimeFlags } from "../../src/effect/runtime-flags" +import { ServerAuth } from "../../src/server/auth" +import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, + envPassword: process.env.OPENCODE_SERVER_PASSWORD, + envUsername: process.env.OPENCODE_SERVER_USERNAME, + } + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword) + restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername) + }), + ) + }), +) + +const fsUtilLayer = AppNodeBuilder.build(FSUtil.node) +const it = testEffect(Layer.mergeAll(testStateLayer, fsUtilLayer, RuntimeFlags.layer())) + +function authConfigLayer(input?: { password?: string; username?: string }) { + return ServerAuth.Config.configLayer({ + password: input?.password === undefined ? Option.none() : Option.some(input.password), + username: input?.username ?? "opencode", + }) +} + +function restoreEnv(key: string, value: string | undefined) { + if (value === undefined) { + delete process.env[key] + return + } + process.env[key] = value +} + +function app(input?: { password?: string; username?: string }) { + const handler = HttpRouter.toWebHandler( + HttpApiApp.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_SERVER_PASSWORD: input?.password, + OPENCODE_SERVER_USERNAME: input?.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + return { + request(input: string | URL | Request, init?: RequestInit) { + return Effect.promise(() => + Promise.resolve( + handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + HttpApiApp.context, + ), + ), + ) + }, + } +} + +function uiApp(input?: { + password?: string + username?: string + client?: Layer.Layer + disableEmbeddedWebUi?: boolean +}) { + const handler = HttpRouter.toWebHandler( + HttpRouter.use((router) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const client = yield* HttpClient.HttpClient + const flags = yield* RuntimeFlags.Service + yield* router.add("*", "/*", (request) => + serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }), + ) + }), + ).pipe( + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(authConfigLayer(input)))), + Layer.provide([ + fsUtilLayer, + input?.client ?? httpClient(new Response("ui")), + RuntimeFlags.layer({ disableEmbeddedWebUi: input?.disableEmbeddedWebUi ?? false }), + HttpServer.layerServices, + ]), + ), + { disableLogger: true }, + ).handler + return { + request(input: string | URL | Request, init?: RequestInit) { + return Effect.promise(() => + Promise.resolve( + handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + HttpApiApp.context, + ), + ), + ) + }, + } +} + +function routeOrderingApp() { + let proxiedUrl: string | undefined + const handler = HttpRouter.toWebHandler( + HttpRouter.use((router) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const client = yield* HttpClient.HttpClient + const flags = yield* RuntimeFlags.Service + yield* router.add("GET", "/session/:sessionID", () => + Effect.succeed(HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })), + ) + yield* router.add("*", "/*", (request) => + serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }), + ) + }), + ).pipe( + Layer.provide([ + fsUtilLayer, + RuntimeFlags.layer({ disableEmbeddedWebUi: true }), + httpClient(new Response("ui"), (request) => { + proxiedUrl = request.url + }), + HttpServer.layerServices, + ]), + ), + { disableLogger: true }, + ).handler + return { + proxiedUrl: () => proxiedUrl, + request(input: string | URL | Request, init?: RequestInit) { + return Effect.promise(() => + Promise.resolve( + handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + HttpApiApp.context, + ), + ), + ) + }, + } +} + +function httpClient(response: Response, onRequest?: (request: HttpClientRequest.HttpClientRequest) => void) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + onRequest?.(request) + return Effect.succeed(HttpClientResponse.fromWeb(request, response)) + }), + ) +} + +function responseText(response: Response) { + return Effect.promise(() => response.text()) +} + +describe("HttpApi UI fallback", () => { + it.live("serves the web UI through the HTTP API app", () => + Effect.gen(function* () { + let proxiedUrl: string | undefined + + const response = yield* uiApp({ + disableEmbeddedWebUi: true, + client: httpClient( + new Response("opencode", { headers: { "content-type": "text/html" } }), + (request) => { + proxiedUrl = request.url + }, + ), + }).request("/") + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("text/html") + expect(yield* responseText(response)).toBe("opencode") + expect(proxiedUrl).toBe("https://app.opencode.ai/") + }), + ) + + it.live("strips upstream transfer encoding headers from proxied assets", () => + Effect.gen(function* () { + let proxiedUrl: string | undefined + + const response = yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const client = yield* HttpClient.HttpClient + const flags = yield* RuntimeFlags.Service + return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), { + fs, + client, + disableEmbeddedWebUi: flags.disableEmbeddedWebUi, + }) + }).pipe( + Effect.provide( + Layer.mergeAll( + RuntimeFlags.layer({ disableEmbeddedWebUi: true }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + proxiedUrl = request.url + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("console.log('ok')", { + headers: { + "content-encoding": "br", + "content-length": "999", + "content-type": "text/javascript", + }, + }), + ), + ) + }), + ), + ), + ), + Effect.map(HttpServerResponse.toWeb), + ) + + expect(response.status).toBe(200) + expect(proxiedUrl).toBe("https://app.opencode.ai/assets/app.js") + expect(response.headers.get("content-encoding")).toBeNull() + expect(response.headers.get("content-length")).not.toBe("999") + expect(response.headers.get("content-type")).toContain("text/javascript") + expect(yield* responseText(response)).toBe("console.log('ok')") + }), + ) + + // Regression for #25698 (Ope): upstream `transfer-encoding: chunked` was + // forwarded through the proxy while the proxy itself re-frames the body, + // causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`. + it.live("strips upstream transfer-encoding header from proxied assets", () => + Effect.gen(function* () { + const response = yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const client = yield* HttpClient.HttpClient + const flags = yield* RuntimeFlags.Service + return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), { + fs, + client, + disableEmbeddedWebUi: flags.disableEmbeddedWebUi, + }) + }).pipe( + Effect.provide( + Layer.mergeAll( + RuntimeFlags.layer({ disableEmbeddedWebUi: true }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("opencode", { + headers: { + "transfer-encoding": "chunked", + "content-type": "text/html", + }, + }), + ), + ), + ), + ), + ), + ), + Effect.map(HttpServerResponse.toWeb), + ) + + expect(response.status).toBe(200) + expect(response.headers.get("transfer-encoding")).toBeNull() + expect(yield* responseText(response)).toBe("opencode") + }), + ) + + it.live("serves embedded UI assets when Bun can read them but access reports missing", () => + Effect.gen(function* () { + let readPath: string | undefined + + const fs = yield* FSUtil.Service + const response = yield* serveEmbeddedUIEffect( + "/assets/app.js", + { + ...fs, + existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"), + readFile: (path) => { + readPath = path + return path === "/$bunfs/root/assets/app.js" + ? Effect.succeed(new TextEncoder().encode("console.log('embedded')")) + : Effect.die(`unexpected embedded UI path: ${path}`) + }, + }, + { "assets/app.js": "/$bunfs/root/assets/app.js" }, + ).pipe(Effect.map(HttpServerResponse.toWeb)) + + expect(response.status).toBe(200) + expect(readPath).toBe("/$bunfs/root/assets/app.js") + expect(response.headers.get("content-type")).toContain("text/javascript") + expect(yield* responseText(response)).toBe("console.log('embedded')") + }), + ) + + it.live("allows embedded UI terminal wasm, blob attachments, and theme preload CSP", () => + Effect.gen(function* () { + const script = 'document.documentElement.dataset.theme = "dark"' + + const fs = yield* FSUtil.Service + const response = yield* serveEmbeddedUIEffect( + "/", + { + ...fs, + readFile: (path) => { + return path === "/$bunfs/root/index.html" + ? Effect.succeed( + new TextEncoder().encode( + ``, + ), + ) + : Effect.die(`unexpected embedded UI path: ${path}`) + }, + }, + { "index.html": "/$bunfs/root/index.html" }, + ).pipe(Effect.map(HttpServerResponse.toWeb)) + + const csp = response.headers.get("content-security-policy") ?? "" + expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'") + expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`) + expect(csp).toContain("img-src 'self' data: https: blob:") + expect(csp).toContain("connect-src * data: blob:") + }), + ) + + it.live("keeps matched API routes ahead of the UI fallback", () => + Effect.gen(function* () { + const server = routeOrderingApp() + const response = yield* server.request("/session/ses_nope") + + expect(response.status).toBe(404) + expect(server.proxiedUrl()).toBeUndefined() + }), + ) + + it.live("requires server password for the web UI", () => + Effect.gen(function* () { + const response = yield* uiApp({ + password: "secret", + username: "opencode", + disableEmbeddedWebUi: true, + }).request("/") + + expect(response.status).toBe(401) + expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"') + }), + ) + + it.live("accepts auth token for the web UI", () => + Effect.gen(function* () { + const response = yield* uiApp({ + password: "secret", + username: "opencode", + disableEmbeddedWebUi: true, + client: httpClient(new Response("opencode", { headers: { "content-type": "text/html" } })), + }).request(`/?auth_token=${btoa("opencode:secret")}`) + + expect(response.status).toBe(200) + expect(yield* responseText(response)).toBe("opencode") + }), + ) + + it.live("accepts basic auth for the web UI", () => + Effect.gen(function* () { + const response = yield* uiApp({ + password: "secret", + username: "opencode", + disableEmbeddedWebUi: true, + }).request("/", { + headers: { authorization: `Basic ${btoa("opencode:secret")}` }, + }) + + expect(response.status).toBe(200) + }), + ) + + it.live("accepts basic auth passwords containing colons for the web UI", () => + Effect.gen(function* () { + const response = yield* uiApp({ + password: "sec:ret", + username: "opencode", + disableEmbeddedWebUi: true, + }).request("/", { + headers: { authorization: `Basic ${btoa("opencode:sec:ret")}` }, + }) + + expect(response.status).toBe(200) + }), + ) + + // Regression for #25698 (Ope): the browser fetches the PWA manifest and + // its icons via flows that don't carry app-managed credentials (the + // `` request is not under page-auth control), so the + // server returning 401 breaks PWA install. These specific public assets + // should bypass auth. + it.live("serves the PWA manifest without auth even when a server password is set", () => + Effect.gen(function* () { + for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) { + const response = yield* uiApp({ + password: "secret", + username: "opencode", + disableEmbeddedWebUi: true, + client: httpClient(new Response("ok")), + }).request(path) + expect(response.status).not.toBe(401) + } + }), + ) + + it.live("allows web UI preflight without auth", () => + Effect.gen(function* () { + const response = yield* app({ password: "secret", username: "opencode" }).request("/", { + method: "OPTIONS", + headers: { + origin: "http://localhost:3000", + "access-control-request-method": "GET", + }, + }) + + expect(response.status).toBe(204) + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf009812b869ff4b093932349d9cd62f0e1a1224 --- /dev/null +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { Context, Schema } from "effect" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +const context = Context.empty() as Context.Context + +function request(route: string, directory: string, init: RequestInit = {}) { + const headers = new Headers(init.headers) + headers.set("x-opencode-directory", directory) + return HttpApiApp.webHandler().handler( + new Request(`http://localhost${route}`, { + ...init, + headers, + }), + context, + ) +} + +const Event = Schema.Struct({ + id: EventV2.ID, + type: Schema.String, + location: Schema.optional(Location.Ref), + data: Schema.Unknown, +}) + +async function* eventStream(body: ReadableStream) { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = "" + try { + while (true) { + const boundary = buffer.match(/(?:\r\n|\r|\n){2}/) + if (!boundary || boundary.index === undefined) { + const value = await reader.read() + if (value.done) return + buffer += decoder.decode(value.value, { stream: true }) + continue + } + + const record = buffer.slice(0, boundary.index) + buffer = buffer.slice(boundary.index + boundary[0].length) + const data = record + .split(/\r\n|\r|\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + if (data.length) yield Schema.decodeUnknownSync(Event)(JSON.parse(data.join("\n"))) + } + } finally { + try { + await reader.cancel() + } finally { + reader.releaseLock() + } + } +} + +async function readEvent(reader: AsyncIterator) { + const value = await reader.next() + if (value.done) throw new Error("event stream closed") + return value.value +} + +async function readEventType(reader: AsyncIterator, type: string) { + for (let index = 0; index < 20; index++) { + const event = await readEvent(reader) + if (event.type === type) return event + } + throw new Error(`timed out waiting for ${type}`) +} + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("v2 location HttpApi", () => { + test("decodes EventV2 location refs without resolved project metadata", () => { + expect( + Schema.decodeUnknownSync(Event)({ + id: "evt_test", + type: "file.watcher.updated", + location: { directory: "/tmp/project" }, + data: {}, + }), + ).toMatchObject({ location: { directory: "/tmp/project" } }) + }) + + test("returns command and skill snapshots with resolved locations", async () => { + await using tmp = await tmpdir({ git: true }) + + for (const route of ["/api/command", "/api/skill"]) { + const response = await request(route, tmp.path) + expect(response.status).toBe(200) + const body = (await response.json()) as { + location: { directory: string; project: { id: string } } + data: unknown + } + expect(body.data).toBeArray() + expect(body.location.directory).toBe(tmp.path) + expect(body.location.project.id).toBeTruthy() + } + }) + + test("streams native EventV2 payloads across locations", async () => { + await using subscriber = await tmpdir({ git: true }) + await using publisher = await tmpdir({ git: true }) + const response = await request("/api/event", subscriber.path) + const reader = eventStream(response.body!) + const connected = await readEvent(reader) + expect(connected.type).toBe("server.connected") + expect(connected.location).toBeUndefined() + + const created = await request("/session", publisher.path, { method: "POST" }) + expect(created.status).toBe(200) + expect(await readEventType(reader, "session.created")).toMatchObject({ + type: "session.created", + location: { directory: publisher.path }, + data: { sessionID: expect.any(String) }, + }) + await reader.return(undefined) + }) +}) diff --git a/packages/opencode/test/server/httpapi-v2-pty.test.ts b/packages/opencode/test/server/httpapi-v2-pty.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea4b02dc83522d64252e6e428f5d05f6acd3d828 --- /dev/null +++ b/packages/opencode/test/server/httpapi-v2-pty.test.ts @@ -0,0 +1,250 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Context, Config as EffectConfig, Effect, Layer, Queue, Schema } from "effect" +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import path from "path" +import { pathToFileURL } from "url" +import { mkdir } from "fs/promises" +import { Location } from "@opencode-ai/core/location" +import { Pty } from "@opencode-ai/core/pty" +import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const context = Context.empty() as Context.Context +const testPty = process.platform === "win32" ? test.skip : test + +function request(route: string, directory: string, init: RequestInit = {}) { + const headers = new Headers(init.headers) + headers.set("x-opencode-directory", directory) + return HttpApiApp.webHandler().handler( + new Request(`http://localhost${route}`, { + ...init, + headers, + }), + context, + ) +} + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase())) + }), +) + +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { disableListenLog: true, disableLogger: true }, +) + +const effectIt = testEffect( + Layer.mergeAll( + testStateLayer, + Socket.layerWebSocketConstructorGlobal, + servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), + ), + ), +) + +const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir) + +const serverUrl = () => HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("v2 pty HttpApi", () => { + testPty("serves location-wrapped PTY routes and retains exited sessions", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + + const empty = await request("/api/pty", tmp.path) + expect(empty.status).toBe(200) + expect(Schema.decodeUnknownSync(Location.response(Schema.Array(Pty.Info)))(await empty.json()).data).toEqual([]) + + const created = await request("/api/pty", tmp.path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "exit 4"], title: "v2" }), + }) + expect(created.status).toBe(200) + const body = Schema.decodeUnknownSync(Location.response(Pty.Info))(await created.json()) + expect(String(body.location.directory)).toBe(tmp.path) + expect(body.data.title).toBe("v2") + + // The canonical surface keeps exited sessions observable with their exit code. + const deadline = Date.now() + 20_000 + let info: { status: string; exitCode?: number } | undefined + while (Date.now() < deadline) { + const found = await request(`/api/pty/${body.data.id}`, tmp.path) + expect(found.status).toBe(200) + info = Schema.decodeUnknownSync(Location.response(Pty.Info))(await found.json()).data + if (info.status === "exited") break + await new Promise((resolve) => setTimeout(resolve, 50)) + } + expect(info).toMatchObject({ status: "exited", exitCode: 4 }) + + const removed = await request(`/api/pty/${body.data.id}`, tmp.path, { method: "DELETE" }) + expect(removed.status).toBe(204) + + const missing = await request(`/api/pty/${body.data.id}`, tmp.path) + expect(missing.status).toBe(404) + expect(await missing.json()).toMatchObject({ _tag: "PtyNotFoundError", ptyID: body.data.id }) + }) + + testPty("rejects connect tokens without the CSRF header and connects with a valid ticket", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const created = await request("/api/pty", tmp.path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"] }), + }) + expect(created.status).toBe(200) + const info = Schema.decodeUnknownSync(Location.response(Pty.Info))(await created.json()).data + + try { + const forbidden = await request(`/api/pty/${info.id}/connect-token`, tmp.path, { method: "POST" }) + expect(forbidden.status).toBe(403) + expect(await forbidden.json()).toMatchObject({ _tag: "ForbiddenError" }) + + const token = await request(`/api/pty/${info.id}/connect-token`, tmp.path, { + method: "POST", + headers: { "x-opencode-ticket": "1" }, + }) + expect(token.status).toBe(200) + const ticket = Schema.decodeUnknownSync(Location.response(PtyTicket.ConnectToken))(await token.json()).data.ticket + expect(ticket).toBeTruthy() + + const invalid = await request(`/api/pty/${info.id}/connect?ticket=not-a-ticket`, tmp.path) + expect(invalid.status).toBe(403) + } finally { + await request(`/api/pty/${info.id}`, tmp.path, { method: "DELETE" }) + } + }) + ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)( + "serves PTY websocket output and input through the canonical route", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } }) + const created = yield* HttpClientRequest.post("/api/pty").pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ command: "/bin/cat", title: "v2-websocket" }), + Effect.flatMap(HttpClient.execute), + ) + expect(created.status).toBe(200) + const body = yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json) + const info = body.data + + const socket = yield* Socket.makeWebSocket( + `${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=-1&location[directory]=${encodeURIComponent(dir)}`, + { closeCodeIsError: () => false }, + ) + const messages = yield* Queue.unbounded() + yield* socket + .runRaw((message) => + Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)), + ) + .pipe(Effect.catch(() => Effect.void)) + .pipe(Effect.forkScoped) + const write = yield* socket.writer + + const takeUntil = (expected: string, seen = ""): Effect.Effect => + Effect.gen(function* () { + const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds"))) + if (next.includes(expected)) return next + return yield* takeUntil(expected, next) + }) + + yield* write("ping-v2\n") + expect(yield* takeUntil("ping-v2")).toContain("ping-v2") + yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) + + const removed = yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe( + directoryHeader(dir), + HttpClient.execute, + ) + expect(removed.status).toBe(204) + }), + ) + ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)( + "applies plugin shell environment before forced PTY values", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } }) + const plugin = path.join(dir, "plugin.ts") + const cwd = path.join(dir, "child") + yield* Effect.promise(() => mkdir(cwd)) + yield* Effect.promise(() => + Bun.write( + plugin, + [ + "export default async () => ({", + ' "shell.env": (input, output) => {', + ' output.env.SHARED = "plugin"', + ' output.env.PLUGIN = "plugin"', + ' output.env.TERM = "plugin"', + " output.env.HOOK_CWD = input.cwd", + " },", + "})", + "", + ].join("\n"), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(plugin).href], formatter: false, lsp: false }), + ), + ) + + const created = yield* HttpClientRequest.post("/api/pty").pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ + command: "/bin/sh", + args: ["-c", 'printf "%s|%s|%s|%s|%s\\n" "$CALLER" "$SHARED" "$PLUGIN" "$TERM" "$HOOK_CWD"; sleep 5'], + cwd, + env: { CALLER: "caller", SHARED: "caller", TERM: "caller" }, + }), + Effect.flatMap(HttpClient.execute), + ) + expect(created.status).toBe(200) + const info = (yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json)).data + + const socket = yield* Socket.makeWebSocket( + `${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=0&location[directory]=${encodeURIComponent(dir)}`, + { closeCodeIsError: () => false }, + ) + const messages = yield* Queue.unbounded() + yield* socket + .runRaw((message) => + Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)), + ) + .pipe( + Effect.catch(() => Effect.void), + Effect.forkScoped, + ) + const write = yield* socket.writer + + const takeUntil = (expected: string, seen = ""): Effect.Effect => + Effect.gen(function* () { + const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds"))) + if (next.includes(expected)) return next + return yield* takeUntil(expected, next) + }) + + expect(yield* takeUntil(`caller|plugin|plugin|xterm-256color|${cwd}`)).toContain( + `caller|plugin|plugin|xterm-256color|${cwd}`, + ) + yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) + yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(directoryHeader(dir), HttpClient.execute) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f6195a87ebc36481ce53bd65f2e278c92281994 --- /dev/null +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -0,0 +1,552 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Context, Effect, Layer, Queue, Ref, Schema, Stream } from "effect" +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import Http from "node:http" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { registerAdapter } from "../../src/control-plane/adapters" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" +import { Database } from "@opencode-ai/core/database/database" +import { Project } from "../../src/project/project" +import { Session } from "../../src/session/session" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRouteContext, + workspaceRoutingLayer, +} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { HEADER as FenceHeader } from "../../src/server/shared/fence" +import { resetDatabase } from "../fixture/db" +import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await resetDatabase() + }), + ) + }), +) + +const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true }) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + NodeHttpServer.layerTest, + NodeServices.layer, + workspaceLayer, + Socket.layerWebSocketConstructorGlobal, + ), +) + +type ProxiedRequest = { + url: string + method: string + headers: Record + body: string +} + +type TestHandler = ( + request: HttpServerRequest.HttpServerRequest, +) => Effect.Effect + +const workspaceRoutingTestLayer = workspaceRoutingLayer.pipe( + Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]), +) + +const serverUrl = HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) + +const requestURL = (request: { readonly url: string }) => new URL(request.url, "http://localhost") + +const listenAdditionalServer = (handler: TestHandler) => + Effect.gen(function* () { + const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 })) + const server = Context.get(context, HttpServer.HttpServer) + yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler)) + return HttpServer.formatAddress(server.address) + }) + +const localAdapter = (directory: string): WorkspaceAdapter => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const remoteAdapter = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter => ({ + name: "Remote Test", + description: "Create a remote test workspace", + configure: (info) => ({ ...info, name: "remote-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "remote" as const, url, headers }), +}) + +const eventStreamResponse = () => + HttpServerResponse.text('data: {"payload":{"type":"server.connected","properties":{}}}\n\n', { + contentType: "text/event-stream", + }) + +const syncResponse = (request: HttpServerRequest.HttpServerRequest) => { + const url = requestURL(request) + if (url.pathname === "/base/global/event") return Effect.succeed(eventStreamResponse()) + if (url.pathname === "/base/sync/history") return HttpServerResponse.json([]) + return undefined +} + +const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adapter: WorkspaceAdapter }) => + Effect.acquireRelease( + Effect.gen(function* () { + registerAdapter(input.projectID, input.type, input.adapter) + const workspace = yield* Workspace.Service + return yield* workspace.create({ + type: input.type, + branch: null, + extra: null, + projectID: input.projectID, + }) + }), + (info) => Workspace.use.remove(info.id).pipe(Effect.ignore), + ) + +const createRemoteWorkspace = (input: { + dir: string + projectID: Project.Info["id"] + type: string + url: string + headers?: HeadersInit +}) => + // Workspace.create starts the remote sync loop. The test upstream exposes + // /global/event and /sync/history so middleware proxying sees the remote + // workspace as active, just like production would. + createWorkspace({ + projectID: input.projectID, + type: input.type, + adapter: remoteAdapter(path.join(input.dir, `.${input.type}`), input.url, input.headers), + }) + +const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => + createWorkspace({ + projectID: input.projectID, + type: input.type, + adapter: localAdapter(input.directory), + }) + +const insertRemoteWorkspaceWithoutSync = (input: { + dir: string + projectID: Project.Info["id"] + type: string + url: string +}) => + Effect.gen(function* () { + const id = WorkspaceV2.ID.ascending() + registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url)) + const { db } = yield* Database.Service + yield* db + .insert(WorkspaceTable) + .values({ id, type: input.type, project_id: input.projectID }) + .run() + .pipe(Effect.orDie) + return id + }) + +const startRemoteWorkspaceHttpServer = ( + handler: (request: ProxiedRequest) => Effect.Effect, +) => + listenAdditionalServer((request) => + Effect.gen(function* () { + // Remote workspaces run a sync loop against their target server. These + // bootstrap routes make Workspace.isSyncing(...) true for proxy tests; + // everything else is the request being proxied by the middleware. + const sync = syncResponse(request) + if (sync) return yield* sync + return yield* handler({ + url: request.url, + method: request.method, + headers: request.headers, + body: yield* request.text, + }) + }), + ) + +const listenRemoteWebSocket = () => + listenAdditionalServer((request) => { + const sync = syncResponse(request) + if (sync) return sync + if (requestURL(request).pathname !== "/base/probe") return Effect.succeed(HttpServerResponse.empty({ status: 404 })) + return echoWebSocket(request) + }) + +const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) => + Effect.gen(function* () { + const socket = yield* Effect.orDie(request.upgrade) + const write = yield* socket.writer + yield* socket + .runRaw((message) => write(`echo:${String(message)}`), { + onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe( + Effect.catch(() => Effect.void), + ), + }) + .pipe(Effect.catch(() => Effect.void)) + return HttpServerResponse.empty() + }) + +const ProbeResult = Schema.Struct({ + directory: Schema.String, + workspaceID: Schema.optional(Schema.String), +}) + +const ProbeApi = HttpApi.make("workspace-routing-probe").add( + HttpApiGroup.make("probe") + .add( + HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }), + HttpApiEndpoint.patch("patch", "/probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }), + HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }), + HttpApiEndpoint.get("workspace", WorkspacePaths.list, { + query: WorkspaceRoutingQuery, + success: ProbeResult, + }), + ) + .middleware(WorkspaceRoutingMiddleware), +) + +const routeContextResponse = Effect.gen(function* () { + const route = yield* WorkspaceRouteContext + return { directory: route.directory, workspaceID: route.workspaceID } +}) + +const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) => + handlers + .handle("get", () => routeContextResponse) + .handle("patch", () => Effect.succeed(false)) + .handle("session", () => routeContextResponse) + .handle("workspace", () => routeContextResponse), +) + +const serveProbe = HttpApiBuilder.layer(ProbeApi).pipe( + Layer.provide(probeHandlers), + Layer.provide(workspaceRoutingTestLayer), + Layer.provide(Layer.mock(Session.Service)({})), + HttpRouter.serve, + Layer.build, +) + +describe("HttpApi workspace routing middleware", () => { + it.live("proxies remote workspace HTTP requests through the selected workspace target", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + let forwarded: ProxiedRequest | undefined + + // This starts a second HTTP server that stands in for the opencode server + // backing a remote workspace. The client below still calls the local test + // server; only the middleware should call this server. + const remoteUrl = yield* startRemoteWorkspaceHttpServer((request) => { + forwarded = request + const url = requestURL(request) + return HttpServerResponse.json( + { + proxied: true, + path: url.pathname, + keep: url.searchParams.get("keep"), + workspace: url.searchParams.get("workspace"), + }, + { status: 201, headers: { "x-remote": "yes" } }, + ) + }) + // The adapter target tells the middleware where to proxy selected remote + // workspace requests. Appending /probe to this base should produce + // `${remoteUrl}/base/probe` on the fake remote server above. + const workspace = yield* createRemoteWorkspace({ + dir, + projectID: project.project.id, + type: "remote-http-target", + url: `${remoteUrl}/base`, + headers: { "x-target-auth": "secret" }, + }) + + // The local /probe handler should not run. Selecting a remote workspace + // should make the middleware call HttpApiProxy.http instead. + yield* serveProbe + + const body = '{"title":"Remote workspace request"}' + const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe( + HttpClientRequest.setHeaders({ + "x-opencode-directory": "/secret/path", + "x-opencode-workspace": "internal", + }), + HttpClientRequest.bodyStream( + Stream.make(new TextEncoder().encode('{"title":"Remote '), new TextEncoder().encode('workspace request"}')), + { contentType: "application/json" }, + ), + HttpClient.execute, + Effect.timeout("2 seconds"), + ) + + expect(response.status).toBe(201) + expect(response.headers["x-remote"]).toBe("yes") + expect(yield* response.json).toEqual({ proxied: true, path: "/base/probe", keep: "yes", workspace: null }) + const forwardedURL = forwarded ? requestURL(forwarded) : undefined + // These assertions are the routing contract: append the original path to + // the remote base URL, preserve normal query params, and remove workspace. + expect(forwardedURL?.pathname).toBe("/base/probe") + expect(forwardedURL?.searchParams.get("keep")).toBe("yes") + expect(forwardedURL?.searchParams.get("workspace")).toBeNull() + expect(forwarded?.method).toBe("PATCH") + expect(forwarded?.body).toBe(body) + expect(forwarded?.headers["content-type"]).toBe("application/json") + expect(forwarded?.headers["x-target-auth"]).toBe("secret") + expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined() + expect(forwarded?.headers["x-opencode-workspace"]).toBeUndefined() + }), + ) + + it.live("waits for sync fence headers from remote workspace HTTP responses", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceID = WorkspaceV2.ID.ascending() + const type = "remote-http-fence-target" + const waited = yield* Ref.make<{ workspaceID: WorkspaceV2.ID; state: Record } | undefined>( + undefined, + ) + + const remoteUrl = yield* startRemoteWorkspaceHttpServer(() => + HttpServerResponse.json( + { proxied: true }, + { status: 202, headers: { [FenceHeader]: JSON.stringify({ aggregate: 3 }) } }, + ), + ) + registerAdapter(project.project.id, type, remoteAdapter(path.join(dir, `.${type}`), `${remoteUrl}/base`)) + + const workspace = Workspace.Service.of({ + create: () => Effect.die("unused"), + sessionWarp: () => Effect.die("unused"), + list: () => Effect.die("unused"), + syncList: () => Effect.die("unused"), + get: (id) => + Effect.succeed( + id === workspaceID + ? { + id: workspaceID, + type, + branch: null, + name: "remote-http-fence-target", + directory: null, + extra: null, + projectID: project.project.id, + timeUsed: Date.now(), + } + : undefined, + ), + remove: () => Effect.die("unused"), + status: () => Effect.die("unused"), + isSyncing: () => Effect.succeed(true), + waitForSync: (id, state) => Ref.set(waited, { workspaceID: id, state }), + startWorkspaceSyncing: () => Effect.die("unused"), + }) + + yield* HttpApiBuilder.layer(ProbeApi).pipe( + Layer.provide(probeHandlers), + Layer.provide(workspaceRoutingTestLayer), + Layer.provide(Layer.succeed(Workspace.Service, workspace)), + Layer.provide(Layer.mock(Session.Service)({})), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspaceID}`).pipe(HttpClient.execute) + + expect(response.status).toBe(202) + expect(yield* response.json).toEqual({ proxied: true }) + expect(yield* Ref.get(waited)).toEqual({ workspaceID, state: { aggregate: 3 } }) + }), + ) + + it.live("returns 503 when a remote workspace is not actively syncing", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceID = yield* insertRemoteWorkspaceWithoutSync({ + dir, + projectID: project.project.id, + type: "remote-not-syncing", + url: "http://127.0.0.1:1/base", + }) + + yield* serveProbe + + const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`) + + expect(response.status).toBe(503) + expect(yield* response.text).toBe(`broken sync connection for workspace: ${workspaceID}`) + }), + ) + + it.live("proxies remote workspace WebSocket requests through the selected workspace target", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const remoteUrl = yield* listenRemoteWebSocket() + const workspace = yield* createRemoteWorkspace({ + dir, + projectID: project.project.id, + type: "remote-websocket-target", + url: `${remoteUrl}/base`, + }) + + // The client connects to the local test server. The middleware should + // detect the WebSocket upgrade and proxy it to the remote /base/probe. + yield* serveProbe + + const socket = yield* Socket.makeWebSocket( + `${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`, + { + closeCodeIsError: () => false, + protocols: "chat", + }, + ) + const messages = yield* Queue.unbounded() + yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped) + const write = yield* socket.writer + + expect(yield* Queue.take(messages)).toBe("protocol:chat") + yield* write("hello") + expect(yield* Queue.take(messages)).toBe("echo:hello") + }), + ) + + it.live("returns a missing workspace response for unknown workspace ids", () => + Effect.gen(function* () { + const workspaceID = WorkspaceV2.ID.ascending("wrk_missing") + // If the middleware resolves the workspace first, this handler is never + // reached and the response should be the middleware error response. + yield* serveProbe + + const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`) + + expect(response.status).toBe(500) + expect(yield* response.text).toBe(`Workspace not found: ${workspaceID}`) + }), + ) + + it.live("keeps control-plane routes local even when workspace is selected", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "control-plane-target", + directory: workspaceDir, + }) + + // GET /session is a control-plane route: it lists sessions for the main + // process and should not be redirected into the selected workspace target. + yield* serveProbe + + const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id }) + }), + ) + + it.live("keeps workspace control routes local even when workspace is selected", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "workspace-control-plane-target", + directory: workspaceDir, + }) + + // Workspace CRUD/status routes manage the control plane itself. Selecting + // a workspace should preserve the selected id for handlers, but must not + // swap the route context to the workspace target directory. + yield* serveProbe + + const response = yield* HttpClient.get(`${WorkspacePaths.list}?workspace=${workspace.id}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id }) + }), + ) + + it.live("uses directory query/header fallback when no workspace is selected", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const queryDir = path.join(dir, "query-target") + const headerDir = path.join(dir, "header-target") + yield* serveProbe + + // Without a selected workspace, the middleware falls back to request + // directory hints before using the process cwd. + const queryResponse = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(queryDir)}`) + const headerResponse = yield* HttpClientRequest.get("/probe").pipe( + HttpClientRequest.setHeader("x-opencode-directory", headerDir), + HttpClient.execute, + ) + + expect(queryResponse.status).toBe(200) + expect(yield* queryResponse.json).toEqual({ directory: queryDir, workspaceID: null }) + expect(headerResponse.status).toBe(200) + expect(yield* headerResponse.json).toEqual({ directory: headerDir, workspaceID: null }) + }), + ) + + it.live("routes local workspace requests through WorkspaceRouteContext", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "local-target", + directory: workspaceDir, + }) + + yield* serveProbe + + // /probe is not a control-plane route, so selecting a local workspace + // should swap the route context to the workspace target directory. + const response = yield* HttpClient.get(`/probe?workspace=${workspace.id}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ + directory: workspaceDir, + workspaceID: workspace.id, + }) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cb9f55c847e718682f43c6cc7057f369c934f9d --- /dev/null +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -0,0 +1,506 @@ +import { afterEach, describe, expect, mock } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { Effect, Layer, Stream } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { registerAdapter } from "../../src/control-plane/adapters" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" +import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" +import { Session } from "@/session/session" +import { Database } from "@opencode-ai/core/database/database" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { Server } from "../../src/server/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" +import { Project } from "../../src/project/project" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +const appLayer = AppNodeBuilder.build( + LayerNode.group([Project.node, Session.node, Workspace.node, InstanceStore.node, Database.node, Ripgrep.node]), + [[InstanceStore.bootstrapNode, InstanceBootstrap.node]], +) +const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer)) + +function request(path: string, directory: string, init: RequestInit = {}) { + return requestInDirectory(path, directory, init) +} + +function requestDefault(path: string, directory: string, init: RequestInit = {}) { + return requestInDirectory(path, directory, init) +} + +function requestServer(path: string, directory: string, init: RequestInit = {}) { + const headers = new Headers(init.headers) + headers.set("x-opencode-directory", directory) + return Effect.promise(() => Promise.resolve(Server.Default().app.request(path, { ...init, headers }))) +} + +function localAdapter(directory: string): WorkspaceAdapter { + return { + name: "Local Test", + description: "Create a local test workspace", + configure(info) { + return { + ...info, + name: "local-test", + directory, + } + }, + async create() { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target() { + return { + type: "local" as const, + directory, + } + }, + } +} + +function listedAdapter(directory: string, type: string): WorkspaceAdapter { + return { + name: "Listed Test", + description: "List a local test workspace", + configure(info) { + return { ...info, name: "unused", directory } + }, + async create() {}, + async remove() {}, + list(context) { + return [ + { + type, + name: "listed-test", + branch: "listed/main", + directory, + extra: { listed: true }, + projectID: context?.instance?.project.id ?? missingAdapterContext(), + }, + ] + }, + target() { + return { + type: "local" as const, + directory, + } + }, + } +} + +function missingAdapterContext(): never { + throw new Error("missing workspace adapter context") +} + +function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter { + return { + name: "Remote Test", + description: "Create a remote test workspace", + configure(info) { + return { + ...info, + name: "remote-test", + directory, + } + }, + async create() { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target() { + return { + type: "remote" as const, + url, + headers, + } + }, + } +} + +type ProxiedRequest = { + url: string + method: string + headers: Record + body: string +} + +function listenRemoteHttp(handler: (request: ProxiedRequest) => Response | Promise) { + return Bun.serve({ + port: 0, + async fetch(request) { + return handler({ + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + body: await request.text(), + }) + }, + }) +} + +function eventStreamResponse() { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode('data: {"payload":{"type":"server.connected","properties":{}}}\n\n'), + ) + }, + }), + { + status: 200, + headers: { + "content-type": "text/event-stream", + }, + }, + ) +} + +afterEach(async () => { + mock.restore() + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() +}) + +describe("workspace HttpApi", () => { + it.live("serves read endpoints", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + + const [adapters, workspaces, status] = yield* Effect.all([ + request(WorkspacePaths.adapters, dir), + request(WorkspacePaths.list, dir), + request(WorkspacePaths.status, dir), + ]) + + expect(adapters.status).toBe(200) + expect(yield* adapters.json).toContainEqual({ + type: "worktree", + name: "Worktree", + description: "Create a git worktree", + }) + + expect(workspaces.status).toBe(200) + expect(yield* workspaces.json).toEqual([]) + + expect(status.status).toBe(200) + expect(yield* status.json).toEqual([]) + }), + ) + + it.live("serves mutation endpoints", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) + + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-test", branch: null }), + }) + expect(created.status).toBe(200) + const workspace = (yield* created.json) as Workspace.Info + expect(workspace).toMatchObject({ type: "local-test", name: "local-test" }) + + const session = yield* Session.use.create({}).pipe(provideInstance(dir)) + const warped = yield* request(WorkspacePaths.warp, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: workspace.id, sessionID: session.id }), + }) + expect(warped.status).toBe(204) + + const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + expect(removed.status).toBe(200) + expect(yield* removed.json).toMatchObject({ id: workspace.id }) + + const listed = yield* request(WorkspacePaths.list, dir) + expect(listed.status).toBe(200) + expect(yield* listed.json).toEqual([]) + }), + ) + + it.live("serves list sync endpoint", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const type = `listed-${Math.random().toString(36).slice(2)}` + registerAdapter(project.project.id, type, listedAdapter(path.join(dir, ".listed"), type)) + + const response = yield* request(WorkspacePaths.syncList, dir, { method: "POST" }) + + expect(response.status).toBe(204) + const listed = yield* request(WorkspacePaths.list, dir) + expect(yield* listed.json).toMatchObject([ + { + type, + name: "listed-test", + branch: "listed/main", + directory: path.join(dir, ".listed"), + extra: { listed: true }, + }, + ]) + }), + ) + + it.live("returns a declared not found error when warping into a missing workspace", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const session = yield* Session.use.create({}).pipe(provideInstance(dir)) + const workspaceID = WorkspaceV2.ID.ascending("wrk_missing_warp") + + const response = yield* request(WorkspacePaths.warp, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: workspaceID, sessionID: session.id }), + }) + + expect(response.status).toBe(404) + expect(yield* response.json).toEqual({ + name: "NotFoundError", + data: { message: `Workspace not found: ${workspaceID}` }, + }) + }), + ) + + it.live("creates workspace with the TUI payload shape", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) + + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-test", branch: null }), + }) + + expect(created.status).toBe(200) + expect((yield* created.json) as Workspace.Info).toMatchObject({ + type: "local-test", + name: "local-test", + }) + }), + ) + + it.live("creates a real git worktree workspace via the builtin adapter", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + + const created = yield* requestServer(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }) + + const body = yield* Effect.promise(() => created.text()) + expect({ status: created.status, body }).toMatchObject({ status: 200 }) + const workspace = JSON.parse(body) as Workspace.Info + expect(workspace).toMatchObject({ type: "worktree" }) + }), + ) + + it.live("routes local workspace requests through the workspace target directory", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const workspaceDir = path.join(dir, ".workspace-local") + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-target", localAdapter(workspaceDir)) + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-target", branch: null }), + }) + const workspace = (yield* created.json) as Workspace.Info + + const url = new URL(`http://localhost${InstancePaths.path}`) + url.searchParams.set("workspace", workspace.id) + + const response = yield* request(url.toString(), dir) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ directory: workspaceDir }) + yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + }), + ) + + it.live("proxies remote workspace HTTP requests with sanitized forwarding", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const proxied: ProxiedRequest[] = [] + const remote = listenRemoteHttp((request) => { + proxied.push(request) + const url = new URL(request.url) + if (url.pathname === "/base/global/event") return eventStreamResponse() + if (url.pathname === "/base/event") return eventStreamResponse() + if (url.pathname === "/base/sync/history") return Response.json([]) + return new Response( + JSON.stringify({ + proxied: true, + path: url.pathname, + keep: url.searchParams.get("keep"), + workspace: url.searchParams.get("workspace"), + }), + { + status: 201, + statusText: "Created", + headers: { + "content-length": "999", + "content-type": "application/json", + "x-remote": "yes", + }, + }, + ) + }) + + const project = yield* Project.use.fromDirectory(dir) + registerAdapter( + project.project.id, + "remote-target", + remoteAdapter(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, { + "x-target-auth": "secret", + }), + ) + const created = yield* requestDefault(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "remote-target", branch: null }), + }) + const workspace = (yield* created.json) as Workspace.Info + + const url = new URL("http://localhost/config") + url.searchParams.set("workspace", workspace.id) + url.searchParams.set("keep", "yes") + + try { + const response = yield* requestDefault(url.toString(), dir, { + method: "PATCH", + headers: { + "accept-encoding": "br", + "content-type": "application/json", + "x-opencode-workspace": "internal", + }, + body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + }) + + const responseBody = yield* response.text + expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 }) + expect(response.headers["content-length"]).toBeUndefined() + expect(response.headers["x-remote"]).toBe("yes") + expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null }) + const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config") + expect(forwarded).toEqual([ + { + url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`, + method: "PATCH", + headers: expect.objectContaining({ + "content-type": "application/json", + "x-target-auth": "secret", + }), + body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + }, + ]) + expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory") + expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace") + + const eventURL = new URL(`http://localhost${EventPaths.event}`) + eventURL.searchParams.set("workspace", workspace.id) + const eventResponse = yield* request(eventURL.toString(), dir) + expect(eventResponse.status).toBe(200) + expect(eventResponse.headers["content-type"]).toContain("text/event-stream") + const event = Array.from(yield* eventResponse.stream.pipe(Stream.take(1), Stream.runCollect))[0] + expect(new TextDecoder().decode(event)).toContain("server.connected") + expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true) + } finally { + void remote.stop(true) + yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + } + }), + ) + + it.live("proxies remote workspace requests selected from session ownership", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const proxied: ProxiedRequest[] = [] + const remote = listenRemoteHttp((request) => { + proxied.push(request) + const url = new URL(request.url) + if (url.pathname === "/base/global/event") return eventStreamResponse() + if (url.pathname === "/base/sync/history") return Response.json([]) + return Response.json({ proxied: true, path: new URL(request.url).pathname }) + }) + + const project = yield* Project.use.fromDirectory(dir) + registerAdapter( + project.project.id, + "remote-session-target", + remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), + ) + const created = yield* requestDefault(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "remote-session-target", branch: null }), + }) + const workspace = (yield* created.json) as Workspace.Info + const sessionResponse = yield* requestDefault("/session", dir, { method: "POST" }) + const session = (yield* sessionResponse.json) as Session.Info + const warped = yield* requestDefault(WorkspacePaths.warp, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: workspace.id, sessionID: session.id }), + }) + expect(warped.status).toBe(204) + + try { + const response = yield* requestDefault(`http://localhost/session/${session.id}/message`, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }), + }) + + const responseBody = yield* response.text + expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 }) + expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` }) + expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([ + expect.objectContaining({ + url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`, + method: "POST", + }), + ]) + + const aborted = yield* request(`http://localhost/session/${session.id}/abort`, dir, { method: "POST" }) + expect(aborted.status).toBe(200) + expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/abort`)).toEqual([ + expect.objectContaining({ + url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/abort`, + method: "POST", + body: "", + }), + ]) + } finally { + void remote.stop(true) + yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + } + }), + ) +}) diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..453aa189d5a19f9d260428df9d776b25a8095ac5 --- /dev/null +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -0,0 +1,84 @@ +// Regression: a stored step-finish part with a negative token count made the +// messages endpoint 400. Some providers reported `outputTokens` excluding +// reasoning while also reporting `reasoningTokens` separately, so the +// `outputTokens - reasoningTokens` math in Session.getUsage underflowed to +// negative. The pre-fix `safe()` clamp only guarded against non-finite. The +// strict `NonNegativeInt` schema then made every load of the message list +// fail to encode, killing Desktop boot for every user with such a row. +import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer } from "effect" +import { eq } from "drizzle-orm" + +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { MessageID, PartID } from "../../src/session/schema" +import { Database } from "@opencode-ai/core/database/database" +import { PartTable } from "@opencode-ai/core/session/sql" +import { resetDatabase } from "../fixture/db" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) + +function seedNegativeTokenSession() { + return Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + // Bypass the schema with a direct SQL update to install the + // negative `output` value we want to test loading. + const { db } = yield* Database.Service + yield* db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, + }) + .where(eq(PartTable.id, partID)) + .run() + .pipe(Effect.orDie) + + return info.id + }) +} + +describe("messages endpoint tolerates legacy negative token counts", () => { + it.instance( + "returns 200 even when a step-finish part has tokens.output < 0", + Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase())) + const test = yield* TestInstance + const sessionID = yield* seedNegativeTokenSession() + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}` + const res = yield* requestInDirectory(url, test.directory) + expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) +}) diff --git a/packages/opencode/test/server/project-copy.test.ts b/packages/opencode/test/server/project-copy.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f783622a6cc9fa0fe4f1eefbf3a5a2933243fa4 --- /dev/null +++ b/packages/opencode/test/server/project-copy.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect } from "bun:test" +import { $ } from "bun" +import fs from "fs/promises" +import path from "path" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer } from "effect" +import { HttpClientResponse } from "effect/unstable/http" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Database } from "@opencode-ai/core/database/database" +import { Snapshot } from "@/snapshot" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const testInstanceStore = AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) +const it = testEffect( + Layer.mergeAll( + AppNodeBuilder.build(LayerNode.group([FSUtil.node, Database.node, Snapshot.node])), + testInstanceStore, + httpApiLayer, + ), +) + +function request(directory: string, url: string, init: RequestInit = {}) { + return requestInDirectory(url, directory, init) +} + +function json(response: HttpClientResponse.HttpClientResponse) { + return response.json.pipe(Effect.map((value) => value as T)) +} + +describe("project directories and copies endpoints", () => { + type ProjectDirectory = { directory: string; strategy?: string } + + it.instance( + "lists directories and manages git worktree copies", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const current = yield* request(test.directory, "/project/current") + const projectID = (yield* json<{ id: string }>(current)).id + const base = `/project/${projectID}` + const copies = `/experimental/project/${projectID}/copy?location%5Bdirectory%5D=${encodeURIComponent(test.directory)}` + const createdParent = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy") + const createdDirectory = path.join(createdParent, "copy") + yield* Effect.addFinalizer(() => + Effect.promise(() => fs.rm(createdParent, { recursive: true, force: true })).pipe(Effect.ignore), + ) + + const initial = yield* request(test.directory, `${base}/directories`) + expect(initial.status).toBe(200) + expect(yield* json(initial)).toEqual([{ directory: test.directory }]) + + const generated = yield* request(test.directory, `/experimental/project/${projectID}/copy/generate-name`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ context: undefined }), + }) + expect(generated.status).toBe(200) + expect((yield* json<{ name: string }>(generated)).name).toBeString() + + const create = yield* request(test.directory, copies, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ strategy: "git_worktree", directory: createdParent, name: "copy" }), + }) + expect(create.status).toBe(200) + const created = yield* json<{ directory: string }>(create) + expect(created.directory).toBe(createdDirectory) + + const listed = yield* request(test.directory, `${base}/directories`) + expect(yield* json(listed)).toContainEqual({ + directory: created.directory, + strategy: "git_worktree", + }) + + yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty")) + + const remove = yield* request(test.directory, copies, { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ directory: created.directory, force: false }), + }) + expect(remove.status).toBe(400) + expect(yield* json<{ data: { forceRequired?: boolean } }>(remove)).toMatchObject({ + data: { forceRequired: true }, + }) + + const forced = yield* request(test.directory, copies, { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ directory: created.directory, force: true }), + }) + expect(forced.status).toBe(204) + + const externalDirectory = path.join(test.directory, "..", path.basename(test.directory) + "-http-refresh") + yield* Effect.addFinalizer(() => + Effect.promise(() => fs.rm(externalDirectory, { recursive: true, force: true })).pipe(Effect.ignore), + ) + yield* Effect.promise(() => $`git worktree add --detach ${externalDirectory} HEAD`.cwd(test.directory).quiet()) + const refresh = yield* request( + test.directory, + `/experimental/project/${projectID}/copy/refresh?location%5Bdirectory%5D=${encodeURIComponent(test.directory)}`, + { + method: "POST", + }, + ) + expect(refresh.status).toBe(204) + const refreshed = yield* request(test.directory, `${base}/directories`) + expect(yield* json(refreshed)).toEqual([ + { directory: externalDirectory, strategy: "git_worktree" }, + { directory: test.directory }, + ]) + }), + { git: true }, + ) +}) diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4474bea7d4166727e7ba8336ce65ae0095a25141 --- /dev/null +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect } from "bun:test" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect, Layer } from "effect" +import { HttpClientResponse } from "effect/unstable/http" +import path from "path" +import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" +import { GlobalBus, type GlobalEvent } from "../../src/bus/global" +import { Snapshot } from "../../src/snapshot" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const testInstanceStore = AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) + +const it = testEffect( + Layer.mergeAll(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Snapshot.node])), testInstanceStore, httpApiLayer), +) + +function request(directory: string, url: string, init: RequestInit = {}) { + return requestInDirectory(url, directory, init) +} + +function json(response: HttpClientResponse.HttpClientResponse) { + return response.json.pipe(Effect.map((value) => value as T)) +} + +function collectGlobalEvents() { + return Effect.acquireRelease( + Effect.sync(() => { + const seen: GlobalEvent[] = [] + const on = (event: GlobalEvent) => { + seen.push(event) + } + GlobalBus.on("event", on) + return { seen, on } + }), + ({ on }) => Effect.sync(() => GlobalBus.off("event", on)), + ) +} + +const disposedEvents = (seen: GlobalEvent[], dir: string) => + seen.filter((evt) => evt.directory === dir && evt.payload.type === "server.instance.disposed").length + +describe("project.initGit endpoint", () => { + it.instance("initializes git and reloads immediately", () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const fs = yield* FSUtil.Service + const events = yield* collectGlobalEvents() + + const init = yield* request(tmp.directory, "/project/git/init", { + method: "POST", + }) + const body = yield* json(init) + expect(init.status).toBe(200) + expect(body).toMatchObject({ + id: "global", + vcs: "git", + worktree: tmp.directory, + }) + // Reload behavior: bus emits exactly one server.instance.disposed for the directory. + expect(disposedEvents(events.seen, tmp.directory)).toBe(1) + expect(yield* fs.exists(path.join(tmp.directory, ".git", "opencode"))).toBe(false) + + const current = yield* request(tmp.directory, "/project/current") + expect(current.status).toBe(200) + expect(yield* json(current)).toMatchObject({ + id: "global", + vcs: "git", + worktree: tmp.directory, + }) + + const ctx = yield* InstanceStore.use.reload({ directory: tmp.directory }) + const tracked = yield* Snapshot.Service.use((snapshot) => snapshot.track()).pipe( + Effect.provideService(InstanceRef, ctx), + ) + expect(tracked).toBeTruthy() + }), + ) + + it.instance( + "does not reload when the project is already git", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const events = yield* collectGlobalEvents() + + const init = yield* request(tmp.directory, "/project/git/init", { + method: "POST", + }) + expect(init.status).toBe(200) + expect(yield* json(init)).toMatchObject({ + vcs: "git", + worktree: tmp.directory, + }) + expect(disposedEvents(events.seen, tmp.directory)).toBe(0) + + const current = yield* request(tmp.directory, "/project/current") + expect(current.status).toBe(200) + expect(yield* json(current)).toMatchObject({ + vcs: "git", + worktree: tmp.directory, + }) + }), + { git: true }, + ) +}) diff --git a/packages/opencode/test/server/proxy-util.test.ts b/packages/opencode/test/server/proxy-util.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d13a06bb31a8f71dc9a7b449ef4b98841af03227 --- /dev/null +++ b/packages/opencode/test/server/proxy-util.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test" +import { ProxyUtil } from "../../src/server/proxy-util" + +describe("ProxyUtil", () => { + describe("websocketTargetURL", () => { + test("converts http to ws", () => { + expect(ProxyUtil.websocketTargetURL("http://example.com/path")).toBe("ws://example.com/path") + }) + + test("converts https to wss", () => { + expect(ProxyUtil.websocketTargetURL("https://example.com/path")).toBe("wss://example.com/path") + }) + + test("preserves query params", () => { + expect(ProxyUtil.websocketTargetURL("http://example.com/path?foo=bar")).toBe("ws://example.com/path?foo=bar") + }) + + test("accepts URL objects", () => { + expect(ProxyUtil.websocketTargetURL(new URL("http://localhost:3000/ws"))).toBe("ws://localhost:3000/ws") + }) + }) + + describe("websocketProtocols", () => { + test("returns empty array when no header", () => { + const req = new Request("http://localhost") + expect(ProxyUtil.websocketProtocols(req)).toEqual([]) + }) + + test("parses single protocol", () => { + const req = new Request("http://localhost", { + headers: { "sec-websocket-protocol": "graphql-ws" }, + }) + expect(ProxyUtil.websocketProtocols(req)).toEqual(["graphql-ws"]) + }) + + test("parses multiple protocols", () => { + const req = new Request("http://localhost", { + headers: { "sec-websocket-protocol": "graphql-ws, graphql-transport-ws" }, + }) + expect(ProxyUtil.websocketProtocols(req)).toEqual(["graphql-ws", "graphql-transport-ws"]) + }) + + test("trims whitespace and filters empty", () => { + const req = new Request("http://localhost", { + headers: { "sec-websocket-protocol": " proto1 , , proto2 " }, + }) + expect(ProxyUtil.websocketProtocols(req)).toEqual(["proto1", "proto2"]) + }) + }) + + describe("headers", () => { + test("strips hop-by-hop headers", () => { + const req = new Request("http://localhost", { + headers: { + connection: "keep-alive", + "keep-alive": "timeout=5", + "transfer-encoding": "chunked", + "content-type": "application/json", + }, + }) + const result = ProxyUtil.headers(req) + expect(result.get("connection")).toBeNull() + expect(result.get("keep-alive")).toBeNull() + expect(result.get("transfer-encoding")).toBeNull() + expect(result.get("content-type")).toBe("application/json") + }) + + test("strips opencode-specific headers", () => { + const req = new Request("http://localhost", { + headers: { + "x-opencode-directory": "/home/user/project", + "x-opencode-workspace": "ws_123", + "accept-encoding": "gzip", + "x-custom": "keep", + }, + }) + const result = ProxyUtil.headers(req) + expect(result.get("x-opencode-directory")).toBeNull() + expect(result.get("x-opencode-workspace")).toBeNull() + expect(result.get("accept-encoding")).toBeNull() + expect(result.get("x-custom")).toBe("keep") + }) + + test("merges extra headers", () => { + const req = new Request("http://localhost", { + headers: { "content-type": "application/json" }, + }) + const result = ProxyUtil.headers(req, { "x-auth": "token", "content-type": "text/plain" }) + expect(result.get("x-auth")).toBe("token") + expect(result.get("content-type")).toBe("text/plain") + }) + + test("returns original headers when no extra", () => { + const req = new Request("http://localhost", { + headers: { "content-type": "application/json", "x-foo": "bar" }, + }) + const result = ProxyUtil.headers(req) + expect(result.get("content-type")).toBe("application/json") + expect(result.get("x-foo")).toBe("bar") + }) + + test("accepts plain object (HeadersInit) as input", () => { + const result = ProxyUtil.headers( + { "content-type": "application/json", connection: "keep-alive", "x-custom": "val" }, + { "x-extra": "added" }, + ) + expect(result.get("connection")).toBeNull() + expect(result.get("content-type")).toBe("application/json") + expect(result.get("x-custom")).toBe("val") + expect(result.get("x-extra")).toBe("added") + }) + }) +}) diff --git a/packages/opencode/test/server/sdk-error-shape.test.ts b/packages/opencode/test/server/sdk-error-shape.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d67c6958a92334d016cb138fb77fa5ae291a1ef --- /dev/null +++ b/packages/opencode/test/server/sdk-error-shape.test.ts @@ -0,0 +1,81 @@ +/** + * Regression tests for the SDK error shape — the v2 SDK's `throwOnError: true` + * path used to throw raw values (empty strings or POJOs from JSON-decoded + * error bodies). The TUI catches those and `e.message`/`e.stack` are + * undefined, so users see `[object Object]` or a blank crash. + * + * Both cases must throw a real `Error` instance with a non-empty `.message` + * extracted from the response body, plus `.status` and `.body` attached. + */ +import { afterEach, describe, expect, test } from "bun:test" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { Server } from "../../src/server/server" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function client(directory: string) { + return createOpencodeClient({ + baseUrl: "http://test", + directory, + fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch, + }) +} + +describe("v2 SDK error shape", () => { + test("404 with NamedError body throws a real Error carrying the server message", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { body?: any; status?: number } + expect(err.message).toContain("Session not found") + expect(cause.status).toBe(404) + expect(cause.body).toMatchObject({ + name: "NotFoundError", + data: { message: expect.stringContaining("Session not found") }, + }) + }) + + test("400 schema rejection: SDK extracts the field-level reason from the NamedError body", async () => { + // Canary for the #26631 wire shape. Asserts the contract end-to-end: + // server emits {name:"BadRequest", data:{message, kind}}, SDK's + // wrapClientError extracts .data.message into Error.message. If either + // side regresses (#26457 reverted because both layers were missing), + // this test fails before users see (empty response body). + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + await sdk.sync.history.list({ body: { aggregate: -1 } as any }, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { body?: any; status?: number } + expect(cause.status).toBe(400) + expect(cause.body).toMatchObject({ + name: "BadRequest", + data: { kind: expect.stringMatching(/^(Body|Payload)$/) }, + }) + expect(typeof cause.body.data.message).toBe("string") + expect(cause.body.data.message.length).toBeGreaterThan(0) + // Whatever the server put in data.message must be what the user sees. + expect(err.message).toBe(cause.body.data.message) + }) +}) diff --git a/packages/opencode/test/server/sdk-v1-smoke.test.ts b/packages/opencode/test/server/sdk-v1-smoke.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e771efcbfa9edafc00ff2fb6c38e05a6bfabbb3 --- /dev/null +++ b/packages/opencode/test/server/sdk-v1-smoke.test.ts @@ -0,0 +1,57 @@ +// Smoke test: v1 SDK (the plugin contract) can actually reach core endpoints +// against the current server. v1 generation has been frozen since #5216 +// (2025-12-07) so types may be stale, but runtime calls should still work +// for endpoints the v1 SDK was generated against. +import { afterEach, describe, expect, test } from "bun:test" +import { createOpencodeClient } from "@opencode-ai/sdk" +import { Server } from "../../src/server/server" +import { tmpdir, disposeAllInstances } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function client(directory: string) { + return createOpencodeClient({ + baseUrl: "http://test", + directory, + fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch, + }) +} + +describe("v1 SDK runtime smoke", () => { + test("session.list reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.session.list() + expect(result.error).toBeUndefined() + expect(Array.isArray(result.data)).toBe(true) + }) + + test("path.get reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.path.get() + expect(result.error).toBeUndefined() + expect(result.data).toBeDefined() + }) + + test("config.get reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.config.get() + expect(result.error).toBeUndefined() + expect(result.data).toBeDefined() + }) + + test("session 404: result-tuple path returns the error body", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.session.get({ path: { id: "ses_no_such" } as never }) + expect(result.error).toBeDefined() + // wire body for 404 is NamedError-shaped + expect(result.error).toMatchObject({ name: "NotFoundError" }) + }) +}) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf27c74cbecf41fb593a24e5f19c2cd4a4aec9ba --- /dev/null +++ b/packages/opencode/test/server/session-actions.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, mock } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer } from "effect" +import { Session as SessionNs } from "@/session/session" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer)) + +afterEach(async () => { + mock.restore() + await disposeAllInstances() +}) + +describe("session action routes", () => { + it.instance( + "session routes expose metadata on create, update, get, and fork", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "Content-Type": "application/json" } + + const created = yield* requestInDirectory("/session", test.directory, { + method: "POST", + headers, + body: JSON.stringify({ + title: "meta-session", + metadata: { source: "sdk", trace: { id: "abc" } }, + }), + }) + expect(created.status).toBe(200) + + const session = (yield* created.json) as SessionNs.Info + expect(session.metadata).toEqual({ source: "sdk", trace: { id: "abc" } }) + + const updated = yield* requestInDirectory(`/session/${session.id}`, test.directory, { + method: "PATCH", + headers, + body: JSON.stringify({ metadata: { source: "sdk", trace: { id: "def" }, tags: ["one"] } }), + }) + expect(updated.status).toBe(200) + + const next = (yield* updated.json) as SessionNs.Info + expect(next.metadata).toEqual({ source: "sdk", trace: { id: "def" }, tags: ["one"] }) + + const fetched = yield* requestInDirectory(`/session/${session.id}`, test.directory) + expect(fetched.status).toBe(200) + expect(((yield* fetched.json) as SessionNs.Info).metadata).toEqual(next.metadata) + + const forked = yield* requestInDirectory(`/session/${session.id}/fork`, test.directory, { + method: "POST", + headers, + body: JSON.stringify({}), + }) + expect(forked.status).toBe(200) + + const fork = (yield* forked.json) as SessionNs.Info + expect(fork.metadata).toEqual(next.metadata) + + const reset = yield* requestInDirectory(`/session/${session.id}`, test.directory, { + method: "PATCH", + headers, + body: JSON.stringify({ metadata: {} }), + }) + expect(reset.status).toBe(200) + expect(((yield* reset.json) as SessionNs.Info).metadata).toEqual({}) + + yield* SessionNs.Service.use((svc) => svc.remove(fork.id).pipe(Effect.ignore)) + yield* SessionNs.Service.use((svc) => svc.remove(session.id).pipe(Effect.ignore)) + }), + { git: true }, + ) + + it.instance( + "abort route returns success", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) => + SessionNs.use.remove(created.id).pipe(Effect.ignore), + ) + + const res = yield* requestInDirectory(`/session/${session.id}/abort`, test.directory, { method: "POST" }) + + expect(res.status).toBe(200) + expect(yield* res.json).toBe(true) + }), + { git: true }, + ) + + it.instance( + "experimental background route is a no-op without synchronous subagents", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) => + SessionNs.use.remove(created.id).pipe(Effect.ignore), + ) + + const res = yield* requestInDirectory(`/experimental/session/${session.id}/background`, test.directory, { + method: "POST", + }) + + expect(res.status).toBe(200) + expect(yield* res.json).toBe(false) + }), + { git: true }, + ) +}) diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2a4211ff1a9ab0805b1566bf1fae60279e71a39 --- /dev/null +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -0,0 +1,97 @@ +/** + * Regression test for the same bug class as #26574 (sibling of #26566 and + * #26553). The Desktop app calls GET /session//diff; before #26574 + * the response was Schema-encoded against `Snapshot.FileDiff` with + * `patch: Schema.String` (required), so any session whose stored + * `summary_diffs` had a row without `patch` returned HTTP 400 and the + * session never loaded. Legacy session-level diffs are no longer surfaced, + * but the endpoint remains compatible and must still return successfully. + * + * This test inserts a session row with a missing-patch diff entry and + * asserts that GET /session//diff returns 200 with empty data. + */ +import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer } from "effect" +import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { Storage } from "@/storage/storage" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { MessageID } from "@/session/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Storage.node])), httpApiLayer)) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function pathFor(template: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template) +} + +const withSession = (input?: Parameters[0]) => + Effect.acquireRelease(Session.use.create(input), (created) => Session.use.remove(created.id).pipe(Effect.ignore)) + +describe("session diff with missing patch (#26574)", () => { + it.instance( + "GET /session//diff ignores legacy session-level diff storage", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "missing-patch" }) + + // Mimic legacy/imported on-disk shape: a diff entry with no + // `patch` text. Pre-fix the typed response encoder rejects + // this and returns 400. + yield* Storage.Service.use((storage) => + storage.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]), + ) + + const response = yield* requestInDirectory( + pathFor(SessionPaths.diff, { sessionID: session.id }), + test.directory, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual([]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "GET /session//diff returns requested turn diffs", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "turn-diff" }) + const messageID = MessageID.ascending() + yield* Session.use.updateMessage({ + id: messageID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") }, + summary: { + diffs: [{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }], + }, + } satisfies SessionV1.User) + + const response = yield* requestInDirectory( + `${pathFor(SessionPaths.diff, { sessionID: session.id })}?messageID=${messageID}`, + test.directory, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual([{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) +}) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..354a578b23c909665be48baf5ef588bbf2aafd5f --- /dev/null +++ b/packages/opencode/test/server/session-list.test.ts @@ -0,0 +1,302 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Database } from "@opencode-ai/core/database/database" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { Session as SessionNs } from "@/session/session" +import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" +import { mkdir } from "fs/promises" +import path from "path" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { eq } from "drizzle-orm" +import { testEffect } from "../lib/effect" +import { RuntimeFlags } from "@/effect/runtime-flags" + +const layer = (experimentalWorkspaces: boolean) => + AppNodeBuilder.build(LayerNode.group([Database.node, SessionNs.node, SessionProjector.node]), [ + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces })], + ]) +const it = testEffect(layer(false)) +const itWorkspaces = testEffect(layer(true)) + +const withSession = (input?: Parameters[0]) => + Effect.acquireRelease(SessionNs.use.create(input), (created) => + SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)), + ) + +afterEach(async () => { + await disposeAllInstances() +}) + +describe("session.list", () => { + it.instance( + "does not filter by directory when directory is omitted", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true })) + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) + + const root = yield* withSession({ title: "root" }) + const parent = yield* withSession({ title: "parent" }).pipe( + provideInstance(path.join(test.directory, "packages")), + ) + const current = yield* withSession({ title: "current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode")), + ) + const sibling = yield* withSession({ title: "sibling" }).pipe( + provideInstance(path.join(test.directory, "packages", "app")), + ) + + const ids = (yield* SessionNs.use.list()).map((session) => session.id) + expect(ids).toContain(root.id) + expect(ids).toContain(parent.id) + expect(ids).toContain(current.id) + expect(ids).toContain(sibling.id) + }), + { git: true }, + ) + + it.instance( + "filters by directory when directory is provided", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true })) + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) + + const root = yield* withSession({ title: "root" }) + const parent = yield* withSession({ title: "parent" }).pipe( + provideInstance(path.join(test.directory, "packages")), + ) + const current = yield* withSession({ title: "current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode")), + ) + const sibling = yield* withSession({ title: "sibling" }).pipe( + provideInstance(path.join(test.directory, "packages", "app")), + ) + + const ids = (yield* SessionNs.Service.use((session) => + session.list({ directory: path.join(test.directory, "packages", "opencode") }), + )).map((session) => session.id) + expect(ids).not.toContain(root.id) + expect(ids).not.toContain(parent.id) + expect(ids).toContain(current.id) + expect(ids).not.toContain(sibling.id) + }), + { git: true }, + ) + + itWorkspaces.instance( + "filters by directory when experimental workspaces are enabled", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true })) + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) + + const current = yield* withSession({ title: "current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode")), + ) + const sibling = yield* withSession({ title: "sibling" }).pipe( + provideInstance(path.join(test.directory, "packages", "app")), + ) + + const ids = (yield* SessionNs.Service.use((session) => + session.list({ directory: path.join(test.directory, "packages", "opencode") }), + )).map((session) => session.id) + expect(ids).toContain(current.id) + expect(ids).not.toContain(sibling.id) + }), + { git: true }, + ) + + it.instance( + "matches a session regardless of directory separator on Windows", + () => + Effect.gen(function* () { + if (process.platform !== "win32") return + const test = yield* TestInstance + const dir = path.join(test.directory, "packages", "opencode") + yield* Effect.promise(() => mkdir(dir, { recursive: true })) + + const created = yield* withSession({ title: "separator" }).pipe(provideInstance(dir)) + + // A forward-slash query (e.g. from the SDK/HTTP layer) must still find it — + // this is the regression: backslash-stored vs forward-slash-queried. + const forwardIDs = (yield* SessionNs.Service.use((session) => + session.list({ directory: dir.replaceAll("\\", "/") }), + )).map((session) => session.id) + expect(forwardIDs).toContain(created.id) + + // The native form must keep matching too. + const nativeIDs = (yield* SessionNs.Service.use((session) => session.list({ directory: dir }))).map( + (session) => session.id, + ) + expect(nativeIDs).toContain(created.id) + }), + { git: true }, + ) + + it.instance( + "filters by path and ignores directory when path is provided", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => + mkdir(path.join(test.directory, "packages", "opencode", "src", "deep"), { recursive: true }), + ) + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) + + const parent = yield* withSession({ title: "parent" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode")), + ) + const current = yield* withSession({ title: "current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode", "src")), + ) + const deeper = yield* withSession({ title: "deeper" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode", "src", "deep")), + ) + const sibling = yield* withSession({ title: "sibling" }).pipe( + provideInstance(path.join(test.directory, "packages", "app")), + ) + + const pathIDs = (yield* SessionNs.Service.use((session) => + session.list({ + directory: path.join(test.directory, "packages", "app"), + path: "packages/opencode/src", + }), + )).map((session) => session.id) + expect(pathIDs).not.toContain(parent.id) + expect(pathIDs).toContain(current.id) + expect(pathIDs).toContain(deeper.id) + expect(pathIDs).not.toContain(sibling.id) + + if (process.platform === "win32") { + const windowsPathIDs = (yield* SessionNs.Service.use((session) => + session.list({ path: "packages\\opencode\\src" }), + )).map((session) => session.id) + expect(windowsPathIDs).toContain(current.id) + expect(windowsPathIDs).toContain(deeper.id) + } + }), + { git: true }, + ) + + it.instance( + "falls back to directory when filtering legacy sessions without path", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => + mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true }), + ) + yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) + + const current = yield* withSession({ title: "legacy-current" }).pipe( + provideInstance(path.join(test.directory, "packages", "opencode", "src")), + ) + const sibling = yield* withSession({ title: "legacy-sibling" }).pipe( + provideInstance(path.join(test.directory, "packages", "app")), + ) + + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ path: null }) + .where(eq(SessionTable.id, current.id)) + .run() + .pipe(Effect.orDie) + yield* db + .update(SessionTable) + .set({ path: null }) + .where(eq(SessionTable.id, sibling.id)) + .run() + .pipe(Effect.orDie) + + const pathIDs = (yield* SessionNs.Service.use((session) => + session.list({ + directory: path.join(test.directory, "packages", "opencode", "src"), + path: "packages/opencode/src", + }), + )).map((session) => session.id) + expect(pathIDs).toContain(current.id) + expect(pathIDs).not.toContain(sibling.id) + }), + { git: true }, + ) + + it.instance( + "filters root sessions", + () => + Effect.gen(function* () { + const root = yield* withSession({ title: "root-session" }) + const child = yield* withSession({ title: "child-session", parentID: root.id }) + + const sessions = yield* SessionNs.use.list({ roots: true }) + const ids = sessions.map((session) => session.id) + + expect(ids).toContain(root.id) + expect(ids).not.toContain(child.id) + }), + { git: true }, + ) + + it.instance( + "filters by start time", + () => + Effect.gen(function* () { + yield* withSession({ title: "new-session" }) + const sessions = yield* SessionNs.Service.use((session) => session.list({ start: Date.now() + 86400000 })) + expect(sessions.length).toBe(0) + }), + { git: true }, + ) + + it.instance( + "filters by search term", + () => + Effect.gen(function* () { + yield* withSession({ title: "unique-search-term-abc" }) + yield* withSession({ title: "other-session-xyz" }) + + const sessions = yield* SessionNs.use.list({ search: "unique-search" }) + const titles = sessions.map((session) => session.title) + + expect(titles).toContain("unique-search-term-abc") + expect(titles).not.toContain("other-session-xyz") + }), + { git: true }, + ) + + it.instance( + "respects limit parameter", + () => + Effect.gen(function* () { + yield* withSession({ title: "session-1" }) + yield* withSession({ title: "session-2" }) + yield* withSession({ title: "session-3" }) + + const sessions = yield* SessionNs.use.list({ limit: 2 }) + expect(sessions.length).toBe(2) + }), + { git: true }, + ) + + it.instance( + "includes metadata in listed sessions", + () => + Effect.gen(function* () { + const meta = { source: "sdk", trace: { id: "abc" } } + const created = yield* withSession({ title: "meta-session", metadata: meta }) + + const listed = (yield* SessionNs.Service.use((session) => session.list({ search: "meta-session" }))).find( + (item) => item.id === created.id, + ) + + expect(listed?.metadata).toEqual(meta) + }), + { git: true }, + ) +}) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c47913bde5ab6c6d381e28d2b9020682bd45c831 --- /dev/null +++ b/packages/opencode/test/server/session-messages.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect } from "bun:test" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer } from "effect" +import { HttpClientResponse } from "effect/unstable/http" +import { Session as SessionNs } from "@/session/session" +import { MessageV2 } from "../../src/session/message-v2" + +import { MessageID, PartID, type SessionID } from "../../src/session/schema" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer)) + +const model = { + providerID: ProviderV2.ID.make("test"), + modelID: ModelV2.ID.make("test"), +} + +afterEach(async () => { + await disposeAllInstances() +}) + +const withoutWatcher = (effect: Effect.Effect) => { + if (process.platform !== "win32") return effect + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER + process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true" + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER + else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = previous + }), + ) +} + +const sessionScoped = Effect.acquireRelease(SessionNs.use.create({}), (session) => + SessionNs.use.remove(session.id).pipe(Effect.ignore), +) + +const fill = Effect.fn("SessionMessagesTest.fill")(function* ( + sessionID: SessionID, + count: number, + time = (i: number) => Date.now() + i, +) { + const session = yield* SessionNs.Service + return yield* Effect.forEach( + Array.from({ length: count }, (_, i) => i), + (i) => + Effect.gen(function* () { + const id = MessageID.ascending() + yield* session.updateMessage({ + id, + sessionID, + role: "user", + time: { created: time(i) }, + agent: "test", + model, + tools: {}, + } satisfies SessionV1.User) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: `m${i}`, + } satisfies SessionV1.TextPart) + return id + }), + ) +}) + +function request(path: string) { + return TestInstance.pipe(Effect.flatMap((test) => requestInDirectory(path, test.directory))) +} + +function json(response: HttpClientResponse.HttpClientResponse) { + return response.json.pipe(Effect.map((body) => body as T)) +} + +describe("session messages endpoint", () => { + it.instance( + "returns cursor headers for older pages", + withoutWatcher( + Effect.gen(function* () { + const session = yield* sessionScoped + const ids = yield* fill(session.id, 5) + + const a = yield* request(`/session/${session.id}/message?limit=2`) + expect(a.status).toBe(200) + const aBody = yield* json(a) + expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2)) + const cursor = a.headers["x-next-cursor"] + expect(cursor).toBeTruthy() + expect(a.headers["link"]).toContain('rel="next"') + + const b = yield* request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`) + expect(b.status).toBe(200) + const bBody = yield* json(b) + expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) + }), + ), + { git: true }, + ) + + it.instance( + "keeps full-history responses when limit is omitted", + withoutWatcher( + Effect.gen(function* () { + const session = yield* sessionScoped + const ids = yield* fill(session.id, 3) + + const res = yield* request(`/session/${session.id}/message`) + expect(res.status).toBe(200) + const body = yield* json(res) + expect(body.map((item) => item.info.id)).toEqual(ids) + }), + ), + { git: true }, + ) + + it.instance( + "rejects invalid cursors and missing sessions", + withoutWatcher( + Effect.gen(function* () { + const session = yield* sessionScoped + + const bad = yield* request(`/session/${session.id}/message?limit=2&before=bad`) + expect(bad.status).toBe(400) + + const miss = yield* request(`/session/ses_missing/message?limit=2`) + expect(miss.status).toBe(404) + }), + ), + { git: true }, + ) + + it.instance( + "does not truncate large legacy limit requests", + withoutWatcher( + Effect.gen(function* () { + const session = yield* sessionScoped + yield* fill(session.id, 520) + + const res = yield* request(`/session/${session.id}/message?limit=510`) + expect(res.status).toBe(200) + const body = yield* json(res) + expect(body).toHaveLength(510) + }), + ), + { git: true }, + ) + + it.instance( + "accepts directory query used by workspace routing", + withoutWatcher( + Effect.gen(function* () { + const tmp = yield* TestInstance + const session = yield* sessionScoped + yield* fill(session.id, 1) + + const res = yield* request( + `/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.directory)}`, + ) + expect(res.status).toBe(200) + const body = yield* json(res) + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(1) + }), + ), + { git: true }, + ) +}) diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..82a8ad93955c8b4b3d0a3f7beeb63a7aab73b1a7 --- /dev/null +++ b/packages/opencode/test/server/session-select.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer } from "effect" +import { Session } from "@/session/session" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const it = testEffect(Layer.mergeAll(LayerNode.compile(Session.node), httpApiLayer)) + +describe("tui.selectSession endpoint", () => { + it.instance( + "should return 200 when called with valid session", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const session = yield* Session.use.create({}) + + const response = yield* requestInDirectory("/tui/select-session", tmp.directory, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: session.id }), + }) + + expect(response.status).toBe(200) + const body = yield* response.json + expect(body).toBe(true) + }), + { git: true }, + ) + + it.instance( + "should return 404 when session does not exist", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const nonExistentSessionID = "ses_nonexistent123" + + const response = yield* requestInDirectory("/tui/select-session", tmp.directory, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: nonExistentSessionID }), + }) + + expect(response.status).toBe(404) + }), + { git: true }, + ) + + it.instance( + "should return 400 when session ID format is invalid", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const invalidSessionID = "invalid_session_id" + + const response = yield* requestInDirectory("/tui/select-session", tmp.directory, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: invalidSessionID }), + }) + + expect(response.status).toBe(400) + }), + { git: true }, + ) +}) diff --git a/packages/opencode/test/server/workspace-proxy.test.ts b/packages/opencode/test/server/workspace-proxy.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..869570f62da696d3f4333ff460a2dbc7aa46ba12 --- /dev/null +++ b/packages/opencode/test/server/workspace-proxy.test.ts @@ -0,0 +1,181 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import Http from "node:http" +import { describe, expect } from "bun:test" +import { Context, Effect, Layer, Queue } from "effect" +import { FetchHttpClient, HttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { HttpApiProxy } from "../../src/server/routes/instance/httpapi/middleware/proxy" +import { testEffect } from "../lib/effect" + +function serverUrl() { + return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) +} + +const testServerLayer = Layer.mergeAll( + NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), + NodeServices.layer, + FetchHttpClient.layer, + Socket.layerWebSocketConstructorGlobal, +) +const it = testEffect(testServerLayer) + +type TestHandler = ( + request: HttpServerRequest.HttpServerRequest, +) => Effect.Effect + +function listenServer(handler: TestHandler) { + return Effect.gen(function* () { + yield* HttpServer.serveEffect()(HttpServerRequest.HttpServerRequest.use(handler)) + return yield* serverUrl() + }) +} + +function listenTestServer(handler: TestHandler) { + return Effect.gen(function* () { + // Build into the current test scope so the listener stays alive until the + // test finishes. Using Effect.provide here would release it immediately. + const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 })) + const server = Context.get(context, HttpServer.HttpServer) + yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler)) + return HttpServer.formatAddress(server.address) + }) +} + +function echoWebSocket(request: HttpServerRequest.HttpServerRequest) { + return Effect.gen(function* () { + const socket = yield* Effect.orDie(request.upgrade) + const write = yield* socket.writer + // The upstream announces the negotiated protocol, then echoes every + // received frame. The assertions use those messages to prove proxy flow. + yield* socket + .runRaw((message) => write(`echo:${String(message)}`), { + onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe( + Effect.catch(() => Effect.void), + ), + }) + .pipe(Effect.catch(() => Effect.void)) + return HttpServerResponse.empty() + }) +} + +describe("HttpApi workspace proxy", () => { + it.live("proxies HTTP request and returns streamed response with status and headers", () => + Effect.gen(function* () { + const url = yield* listenServer( + Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) { + const body = yield* req.text + return yield* HttpServerResponse.json( + { path: req.url, method: req.method, body }, + { + status: 201, + headers: { + "content-encoding": "identity", + "content-length": "999", + "x-remote": "yes", + }, + }, + ) + }), + ) + + const request = HttpServerRequest.fromWeb( + new Request("http://localhost/session/abc", { method: "POST", body: "request-body" }), + ) + const httpClient = yield* HttpClient.HttpClient + const response = yield* HttpApiProxy.http( + httpClient, + `${url}/session/abc?keep=yes`, + { "x-extra": "injected" }, + request, + ) + + expect(response.status).toBe(201) + const client = HttpServerResponse.toClientResponse(response) + expect(yield* client.json).toEqual({ + path: "/session/abc?keep=yes", + method: "POST", + body: "request-body", + }) + expect(response.headers["x-remote"]).toBe("yes") + expect(response.headers["content-encoding"]).toBeUndefined() + expect(response.headers["content-length"]).toBeUndefined() + }), + ) + + it.live("returns 500 when remote is unreachable", () => + Effect.gen(function* () { + const request = HttpServerRequest.fromWeb(new Request("http://localhost/anything")) + const httpClient = yield* HttpClient.HttpClient + const response = yield* HttpApiProxy.http(httpClient, "http://127.0.0.1:1/unreachable", undefined, request) + + expect(response.status).toBe(500) + }), + ) + + it.live("proxies bodyless Web mutation requests as an empty body", () => + Effect.gen(function* () { + const url = yield* listenServer( + Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) { + return yield* HttpServerResponse.json({ method: req.method, body: yield* req.text }) + }), + ) + const request = HttpServerRequest.fromWeb(new Request("http://localhost/session/abc/abort", { method: "POST" })) + const httpClient = yield* HttpClient.HttpClient + const response = yield* HttpApiProxy.http(httpClient, `${url}/session/abc/abort`, undefined, request) + + expect(response.status).toBe(200) + expect(yield* HttpServerResponse.toClientResponse(response).json).toEqual({ method: "POST", body: "" }) + }), + ) + + it.live("strips opencode-internal headers and merges extra headers", () => + Effect.gen(function* () { + let forwarded: Record = {} + const url = yield* listenServer((req) => + Effect.sync(() => { + forwarded = req.headers + return HttpServerResponse.empty() + }), + ) + + const request = HttpServerRequest.fromWeb( + new Request("http://localhost/test", { + headers: { + "x-opencode-directory": "/secret/path", + "x-opencode-workspace": "ws_123", + "x-custom": "preserved", + }, + }), + ) + const httpClient = yield* HttpClient.HttpClient + yield* HttpApiProxy.http(httpClient, `${url}/test`, { "x-injected": "extra" }, request) + + expect(forwarded["x-opencode-directory"]).toBeUndefined() + expect(forwarded["x-opencode-workspace"]).toBeUndefined() + expect(forwarded["x-custom"]).toBe("preserved") + expect(forwarded["x-injected"]).toBe("extra") + }), + ) + + it.live("proxies websocket messages and protocols", () => + Effect.gen(function* () { + const upstreamUrl = yield* listenTestServer(echoWebSocket) + + // Client -> proxy listener -> HttpApiProxy.websocket -> upstream listener. + // The client never connects to upstream directly. + const proxyUrl = yield* listenServer((request) => HttpApiProxy.websocket(request, `${upstreamUrl}/echo`)) + + const socket = yield* Socket.makeWebSocket(`${proxyUrl.replace(/^http/, "ws")}/proxy`, { + closeCodeIsError: () => false, + protocols: "chat", + }) + const messages = yield* Queue.unbounded() + yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped) + const write = yield* socket.writer + + expect(yield* Queue.take(messages)).toBe("protocol:chat") + yield* write("hello") + expect(yield* Queue.take(messages)).toBe("echo:hello") + }), + ) +}) diff --git a/packages/opencode/test/server/workspace-routing.test.ts b/packages/opencode/test/server/workspace-routing.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..29c22038c3b6aaf7f4c26a38a4b8f95e0a819375 --- /dev/null +++ b/packages/opencode/test/server/workspace-routing.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test" +import { + isLocalWorkspaceRoute, + getWorkspaceRouteSessionID, + workspaceProxyURL, +} from "../../src/server/shared/workspace-routing" +import { SessionID } from "../../src/session/schema" + +describe("isLocalWorkspaceRoute", () => { + test("GET /session is local", () => { + expect(isLocalWorkspaceRoute("GET", "/session")).toBe(true) + }) + + test("GET /session/ses_abc is local (prefix match)", () => { + expect(isLocalWorkspaceRoute("GET", "/session/ses_abc")).toBe(true) + }) + + test("POST /session is not local (method mismatch)", () => { + expect(isLocalWorkspaceRoute("POST", "/session")).toBe(false) + }) + + test("/session/status is forwarded regardless of method", () => { + expect(isLocalWorkspaceRoute("GET", "/session/status")).toBe(false) + expect(isLocalWorkspaceRoute("POST", "/session/status")).toBe(false) + }) + + test("unrecognized paths are not local", () => { + expect(isLocalWorkspaceRoute("GET", "/config")).toBe(false) + expect(isLocalWorkspaceRoute("POST", "/session/ses_abc/message")).toBe(false) + }) +}) + +describe("getWorkspaceRouteSessionID", () => { + test("extracts session ID from path", () => { + const url = new URL("http://localhost/session/ses_abc123/message") + expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_abc123")) + }) + + test("extracts session ID without trailing path", () => { + const url = new URL("http://localhost/session/ses_xyz") + expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_xyz")) + }) + + test("extracts session ID from experimental background path", () => { + const url = new URL("http://localhost/experimental/session/ses_bg/background") + expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_bg")) + }) + + test("returns null for /session/status", () => { + const url = new URL("http://localhost/session/status") + expect(getWorkspaceRouteSessionID(url)).toBeNull() + }) + + test("returns null for non-session paths", () => { + const url = new URL("http://localhost/config") + expect(getWorkspaceRouteSessionID(url)).toBeNull() + }) + + test("returns null for bare /session path", () => { + const url = new URL("http://localhost/session") + expect(getWorkspaceRouteSessionID(url)).toBeNull() + }) +}) + +describe("workspaceProxyURL", () => { + test("appends request path to target", () => { + const result = workspaceProxyURL("http://remote:8080/base", new URL("http://localhost/config")) + expect(result.toString()).toBe("http://remote:8080/base/config") + }) + + test("strips trailing slash on target before appending", () => { + const result = workspaceProxyURL("http://remote:8080/base/", new URL("http://localhost/session/abc")) + expect(result.pathname).toBe("/base/session/abc") + }) + + test("preserves query params from request but removes workspace", () => { + const url = new URL("http://localhost/config?workspace=ws_123&keep=yes") + const result = workspaceProxyURL("http://remote:8080/base", url) + expect(result.searchParams.get("workspace")).toBeNull() + expect(result.searchParams.get("keep")).toBe("yes") + }) + + test("strips the host directory param so the remote resolves its own root", () => { + const url = new URL("http://localhost/session/abc?directory=F%3A%5Cproj&keep=yes") + const result = workspaceProxyURL("http://remote:8080/base", url) + expect(result.searchParams.get("directory")).toBeNull() + expect(result.searchParams.get("keep")).toBe("yes") + }) + + test("preserves hash from request", () => { + const url = new URL("http://localhost/page#section") + const result = workspaceProxyURL("http://remote:8080", url) + expect(result.hash).toBe("#section") + }) + + test("works with URL object as target", () => { + const target = new URL("http://remote:3000/api") + const result = workspaceProxyURL(target, new URL("http://localhost/users")) + expect(result.toString()).toBe("http://remote:3000/api/users") + }) +}) diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..62a61858861c6948e12ba85468507384996a27b4 --- /dev/null +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -0,0 +1,307 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer, Queue } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Worktree } from "@/worktree" +import { Server } from "../../src/server/server" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const stateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { + OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + } + + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES + await resetDatabase() + }), + ) + }), +) + +const it = testEffect(stateLayer) +const worktreeTest = process.platform === "win32" ? it.instance.skip : it.instance +type TestServer = ReturnType["app"] +type CreatedWorktree = { directory: string } +type ScopedWorktree = { directory: string; body: CreatedWorktree; ready: Effect.Effect } + +function serverScoped() { + return Effect.sync(() => Server.Default().app) +} + +function request(server: TestServer, input: string, init?: RequestInit) { + return Effect.promise(() => Promise.resolve(server.request(input, init))) +} + +function withRequestTimeout(effect: Effect.Effect, label: string, ms = 5_000) { + return effect.pipe( + Effect.timeoutOrElse({ + duration: `${ms} millis`, + orElse: () => Effect.fail(new Error(`${label} timed out after ${ms}ms`)), + }), + ) +} + +function json(response: Response) { + return Effect.promise(() => response.json() as Promise) +} + +function readyWatcher() { + return Effect.gen(function* () { + const events = yield* Queue.bounded(1) + const on = (event: GlobalEvent) => { + if (event.payload.type === Worktree.Event.Ready.type) Queue.offerUnsafe(events, event) + } + + GlobalBus.on("event", on) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) + + return (directory: string) => + Effect.gen(function* () { + while (true) { + const event = yield* Queue.take(events) + if (event.directory === directory) return + } + }).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.fail(new Error(`timed out waiting for worktree.ready: ${directory}`)), + }), + ) + }) +} + +function removeCreatedWorktree(input: { + server: TestServer + rootDirectory: string + worktreeDirectory: string + ready: Effect.Effect +}) { + return Effect.gen(function* () { + yield* input.ready.pipe(Effect.timeout("1 second"), Effect.ignore) + yield* Effect.promise(() => disposeAllInstances()).pipe(Effect.ignore) + + const removed = yield* request( + input.server, + `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(input.rootDirectory)}`, + { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ directory: input.worktreeDirectory }), + }, + ) + if (removed.status !== 200) { + const message = yield* Effect.promise(() => removed.text()) + throw new Error(`failed to remove worktree: ${removed.status} ${message}`) + } + const ok = yield* json(removed) + if (!ok) throw new Error(`failed to remove worktree ${input.worktreeDirectory}`) + }) +} + +function createWorktreeScoped(input: { + server: TestServer + directory: string + path: string + init: RequestInit + timeoutLabel: string + timeoutMs?: number +}) { + return Effect.acquireRelease( + Effect.gen(function* () { + const waitReady = yield* readyWatcher() + const response = yield* withRequestTimeout( + request(input.server, input.path, input.init), + input.timeoutLabel, + input.timeoutMs, + ) + if (response.status !== 200) { + const message = yield* Effect.promise(() => response.text()) + throw new Error(`${input.timeoutLabel} failed: ${response.status} ${message}`) + } + expect(response.status).toBe(200) + const body = yield* json(response) + return { directory: body.directory, body, ready: waitReady(body.directory) } satisfies ScopedWorktree + }), + (created) => + removeCreatedWorktree({ + server: input.server, + rootDirectory: input.directory, + worktreeDirectory: created.directory, + ready: created.ready, + }).pipe(Effect.orDie), + ).pipe(Effect.map((created) => created.body)) +} + +function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) { + return Effect.gen(function* () { + const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`) + expect(current.status).toBe(200) + const project = yield* json<{ id: string }>(current) + const updated = yield* request( + input.server, + `/project/${project.id}?directory=${encodeURIComponent(input.directory)}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ commands: { start: input.command } }), + }, + ) + expect(updated.status).toBe(200) + }) +} + +describe("worktree endpoint reproduction", () => { + worktreeTest( + "direct HttpApi worktree create returns without waiting for boot", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* createWorktreeScoped({ + server, + directory: test.directory, + path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, + init: { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }, + timeoutLabel: "direct worktree create", + }) + + expect(response).toMatchObject({ directory: expect.any(String) }) + }), + { git: true }, + ) + + worktreeTest( + "direct HttpApi worktree create accepts missing body", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* createWorktreeScoped({ + server, + directory: test.directory, + path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, + init: { method: "POST", headers: { "content-type": "application/json" } }, + timeoutLabel: "direct worktree create without body", + }) + + expect(response).toMatchObject({ directory: expect.any(String) }) + }), + { git: true }, + ) + + worktreeTest( + "direct HttpApi worktree create accepts missing content type and body", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* createWorktreeScoped({ + server, + directory: test.directory, + path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, + init: { method: "POST" }, + timeoutLabel: "direct worktree create without content type or body", + }) + + expect(response).toMatchObject({ directory: expect.any(String) }) + }), + { git: true }, + ) + + worktreeTest( + "direct HttpApi worktree create rejects explicit null payload", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* request( + server, + `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: "null", + }, + ) + + expect(response.status).toBe(400) + }), + { git: true }, + ) + + worktreeTest( + "workspace worktree create does not hang", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* createWorktreeScoped({ + server, + directory: test.directory, + path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, + init: { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }, + timeoutLabel: "workspace worktree create", + timeoutMs: 8_000, + }) + + expect(response).toMatchObject({ + type: "worktree", + directory: expect.any(String), + }) + }), + { git: true }, + ) + + worktreeTest( + "workspace worktree create returns without waiting for project start command", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + yield* setProjectStartCommand({ + server, + directory: test.directory, + command: 'bun -e "setTimeout(() => {}, 2000)"', + }) + + const started = Date.now() + yield* createWorktreeScoped({ + server, + directory: test.directory, + path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, + init: { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }, + timeoutLabel: "workspace worktree create with project start command", + timeoutMs: 6_000, + }) + + expect(Date.now() - started).toBeLessThan(1_500) + }), + { git: true }, + ) +})