From 793804b0a6cea21fa939d6a0400db072c1df2922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Tue, 12 May 2026 07:42:54 -0300 Subject: [PATCH 1/9] feat: move model api_key to local-only models_secrets table --- .../0015_panoramic_sheva_callister.sql | 5 + backend/drizzle/meta/0015_snapshot.json | 2076 +++++++++++++++++ backend/drizzle/meta/_journal.json | 7 + backend/src/db/powersync-schema.ts | 1 - src/dal/chat-threads.test.ts | 1 - src/dal/models.test.ts | 117 + src/dal/models.ts | 60 +- src/db/encryption/config.ts | 2 +- src/db/powersync/schema.ts | 25 +- src/db/relations.ts | 13 +- src/db/tables.ts | 7 +- src/defaults/models.ts | 1 - src/types.ts | 3 +- 13 files changed, 2285 insertions(+), 33 deletions(-) create mode 100644 backend/drizzle/0015_panoramic_sheva_callister.sql create mode 100644 backend/drizzle/meta/0015_snapshot.json diff --git a/backend/drizzle/0015_panoramic_sheva_callister.sql b/backend/drizzle/0015_panoramic_sheva_callister.sql new file mode 100644 index 00000000..f5b533af --- /dev/null +++ b/backend/drizzle/0015_panoramic_sheva_callister.sql @@ -0,0 +1,5 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at http://mozilla.org/MPL/2.0/. + +ALTER TABLE "powersync"."models" DROP COLUMN "api_key"; \ No newline at end of file diff --git a/backend/drizzle/meta/0015_snapshot.json b/backend/drizzle/meta/0015_snapshot.json new file mode 100644 index 00000000..c2210287 --- /dev/null +++ b/backend/drizzle/meta/0015_snapshot.json @@ -0,0 +1,2076 @@ +{ + "id": "7428a54d-23a7-4f14-afc9-51ae5a251906", + "prevId": "63c63d23-7a92-4360-9f93-3ab234d071ca", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_deviceId_idx": { + "name": "session_deviceId_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_new": { + "name": "is_new", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "waitlist_status_idx": { + "name": "waitlist_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_batch_id_idx": { + "name": "waitlist_batch_id_idx", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.chat_messages": { + "name": "chat_messages", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parts": { + "name": "parts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_thread_id": { + "name": "chat_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache": { + "name": "cache", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_chat_messages_user_id": { + "name": "idx_chat_messages_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_user_id_user_id_fk": { + "name": "chat_messages_user_id_user_id_fk", + "tableFrom": "chat_messages", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.chat_threads": { + "name": "chat_threads", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_encrypted": { + "name": "is_encrypted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "was_triggered_by_automation": { + "name": "was_triggered_by_automation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "context_size": { + "name": "context_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mode_id": { + "name": "mode_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_chat_threads_user_id": { + "name": "idx_chat_threads_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_user_id_user_id_fk": { + "name": "chat_threads_user_id_user_id_fk", + "tableFrom": "chat_threads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.devices": { + "name": "devices", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trusted": { + "name": "trusted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "approval_pending": { + "name": "approval_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mlkem_public_key": { + "name": "mlkem_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_devices_user_id": { + "name": "idx_devices_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_user_id_fk": { + "name": "devices_user_id_user_id_fk", + "tableFrom": "devices", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.mcp_servers": { + "name": "mcp_servers", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'http'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_mcp_servers_user_id": { + "name": "idx_mcp_servers_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_user_id_user_id_fk": { + "name": "mcp_servers_user_id_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.model_profiles": { + "name": "model_profiles", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "max_steps": { + "name": "max_steps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "nudge_threshold": { + "name": "nudge_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "use_system_message_mode_developer": { + "name": "use_system_message_mode_developer", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "tools_override": { + "name": "tools_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_previews_override": { + "name": "link_previews_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_mode_addendum": { + "name": "chat_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_mode_addendum": { + "name": "search_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "research_mode_addendum": { + "name": "research_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "citation_reinforcement_enabled": { + "name": "citation_reinforcement_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "citation_reinforcement_prompt": { + "name": "citation_reinforcement_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_final_step": { + "name": "nudge_final_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_preventive": { + "name": "nudge_preventive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_retry": { + "name": "nudge_retry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_final_step": { + "name": "nudge_search_final_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_preventive": { + "name": "nudge_search_preventive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_retry": { + "name": "nudge_search_retry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_options": { + "name": "provider_options", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_model_profiles_user_id": { + "name": "idx_model_profiles_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_profiles_user_id_user_id_fk": { + "name": "model_profiles_user_id_user_id_fk", + "tableFrom": "model_profiles", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "model_profiles_id_user_id_pk": { + "name": "model_profiles_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.models": { + "name": "models", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "tool_usage": { + "name": "tool_usage", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_confidential": { + "name": "is_confidential", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "start_with_reasoning": { + "name": "start_with_reasoning", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "supports_parallel_tool_calls": { + "name": "supports_parallel_tool_calls", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_models_user_id": { + "name": "idx_models_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "models_user_id_user_id_fk": { + "name": "models_user_id_user_id_fk", + "tableFrom": "models", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "models_id_user_id_pk": { + "name": "models_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.modes": { + "name": "modes", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_modes_user_id": { + "name": "idx_modes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "modes_user_id_user_id_fk": { + "name": "modes_user_id_user_id_fk", + "tableFrom": "modes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "modes_id_user_id_pk": { + "name": "modes_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.prompts": { + "name": "prompts", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_prompts_user_id": { + "name": "idx_prompts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prompts_user_id_user_id_fk": { + "name": "prompts_user_id_user_id_fk", + "tableFrom": "prompts", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "prompts_id_user_id_pk": { + "name": "prompts_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.settings": { + "name": "settings", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_settings_user_id": { + "name": "idx_settings_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "settings_id_user_id_pk": { + "name": "settings_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.tasks": { + "name": "tasks", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item": { + "name": "item", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "is_complete": { + "name": "is_complete", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_tasks_user_id": { + "name": "idx_tasks_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_user_id_user_id_fk": { + "name": "tasks_user_id_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tasks_id_user_id_pk": { + "name": "tasks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.triggers": { + "name": "triggers", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_time": { + "name": "trigger_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_triggers_user_id": { + "name": "idx_triggers_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "triggers_user_id_user_id_fk": { + "name": "triggers_user_id_user_id_fk", + "tableFrom": "triggers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limits": { + "name": "rate_limits", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expire": { + "name": "expire", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rate_limits_expire_idx": { + "name": "rate_limits_expire_idx", + "columns": [ + { + "expression": "expire", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encryption_metadata": { + "name": "encryption_metadata", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "canary_iv": { + "name": "canary_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canary_ctext": { + "name": "canary_ctext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canary_secret_hash": { + "name": "canary_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "encryption_metadata_user_id_user_id_fk": { + "name": "encryption_metadata_user_id_user_id_fk", + "tableFrom": "encryption_metadata", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.envelopes": { + "name": "envelopes", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wrapped_ck": { + "name": "wrapped_ck", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_envelopes_user_id": { + "name": "idx_envelopes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "envelopes_device_id_devices_id_fk": { + "name": "envelopes_device_id_devices_id_fk", + "tableFrom": "envelopes", + "tableTo": "devices", + "schemaTo": "powersync", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "envelopes_user_id_user_id_fk": { + "name": "envelopes_user_id_user_id_fk", + "tableFrom": "envelopes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_challenge": { + "name": "otp_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge_token": { + "name": "challenge_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "otp_challenge_email_unique": { + "name": "otp_challenge_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 5c96738b..d8968dcf 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1778457600000, "tag": "0014_revoke_limbo_devices", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1778537774824, + "tag": "0015_panoramic_sheva_callister", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/powersync-schema.ts b/backend/src/db/powersync-schema.ts index 93f010f8..34496819 100644 --- a/backend/src/db/powersync-schema.ts +++ b/backend/src/db/powersync-schema.ts @@ -104,7 +104,6 @@ export const modelsTable = powersyncSchema.table( name: text('name'), model: text('model'), url: text('url'), - apiKey: text('api_key'), isSystem: integer('is_system').default(0), enabled: integer('enabled').default(1), toolUsage: integer('tool_usage').default(1), diff --git a/src/dal/chat-threads.test.ts b/src/dal/chat-threads.test.ts index 733db182..ac16cdaf 100644 --- a/src/dal/chat-threads.test.ts +++ b/src/dal/chat-threads.test.ts @@ -50,7 +50,6 @@ const createTestModel = async () => { toolUsage: 1, startWithReasoning: 0, deletedAt: null, - apiKey: null, url: null, defaultHash: null, }) diff --git a/src/dal/models.test.ts b/src/dal/models.test.ts index 346c824c..646fc8f7 100644 --- a/src/dal/models.test.ts +++ b/src/dal/models.test.ts @@ -7,6 +7,7 @@ import { chatMessagesTable, chatThreadsTable, modelProfilesTable, + modelsSecretsTable, modelsTable, promptsTable, triggersTable, @@ -1019,6 +1020,122 @@ describe('Models DAL', () => { }) }) + describe('models_secrets (local-only table)', () => { + it('should store apiKey in models_secrets when creating a model with apiKey', async () => { + const db = getDb() + const modelId = uuidv7() + + await createModel(db, { + id: modelId, + provider: 'openai', + name: 'Model with key', + model: 'gpt-4', + apiKey: 'sk-test-key', + }) + + // apiKey should NOT be on the models table + const rawModel = await db.select().from(modelsTable).where(eq(modelsTable.id, modelId)).get() + expect(rawModel).not.toBeUndefined() + expect('apiKey' in (rawModel ?? {})).toBe(false) + + // apiKey should be in the secrets table + const secret = await db.select().from(modelsSecretsTable).where(eq(modelsSecretsTable.modelId, modelId)).get() + expect(secret?.apiKey).toBe('sk-test-key') + }) + + it('should not create a secret row when apiKey is null', async () => { + const db = getDb() + const modelId = uuidv7() + + await createModel(db, { + id: modelId, + provider: 'thunderbolt', + name: 'System Model', + model: 'gpt-oss-120b', + apiKey: null, + }) + + const secret = await db.select().from(modelsSecretsTable).where(eq(modelsSecretsTable.modelId, modelId)).get() + expect(secret).toBeUndefined() + }) + + it('should return apiKey from getModel via LEFT JOIN', async () => { + const db = getDb() + const modelId = uuidv7() + + await createModel(db, { + id: modelId, + provider: 'openai', + name: 'Model with key', + model: 'gpt-4', + apiKey: 'sk-joined-key', + }) + + const model = await getModel(db, modelId) + expect(model?.apiKey).toBe('sk-joined-key') + }) + + it('should return apiKey as null when no secret exists', async () => { + const db = getDb() + const modelId = uuidv7() + + await createModel(db, { + id: modelId, + provider: 'thunderbolt', + name: 'No key model', + model: 'gpt-oss-120b', + }) + + const model = await getModel(db, modelId) + expect(model?.apiKey).toBeNull() + }) + + it('should upsert apiKey via updateModel', async () => { + const db = getDb() + const modelId = uuidv7() + + await createModel(db, { + id: modelId, + provider: 'openai', + name: 'Test Model', + model: 'gpt-4', + }) + + // First update creates the secret + await updateModel(db, modelId, { apiKey: 'sk-first' }) + let model = await getModel(db, modelId) + expect(model?.apiKey).toBe('sk-first') + + // Second update replaces it + await updateModel(db, modelId, { apiKey: 'sk-second' }) + model = await getModel(db, modelId) + expect(model?.apiKey).toBe('sk-second') + }) + + it('should hard-delete secret when model is deleted', async () => { + const db = getDb() + const modelId = uuidv7() + + await createModel(db, { + id: modelId, + provider: 'openai', + name: 'Model to delete', + model: 'gpt-4', + apiKey: 'sk-to-delete', + }) + + // Verify secret exists + let secret = await db.select().from(modelsSecretsTable).where(eq(modelsSecretsTable.modelId, modelId)).get() + expect(secret?.apiKey).toBe('sk-to-delete') + + await deleteModel(db, modelId) + + // Secret should be gone (hard-deleted) + secret = await db.select().from(modelsSecretsTable).where(eq(modelsSecretsTable.modelId, modelId)).get() + expect(secret).toBeUndefined() + }) + }) + describe('deleteModel profile cascade', () => { it('should soft-delete the model profile when deleting a model', async () => { const db = getDb() diff --git a/src/dal/models.ts b/src/dal/models.ts index 9bab3c19..bbfc8e29 100644 --- a/src/dal/models.ts +++ b/src/dal/models.ts @@ -4,20 +4,31 @@ import { and, desc, eq, getTableColumns, isNotNull, isNull, or, sql } from 'drizzle-orm' import type { AnyDrizzleDatabase } from '../db/database-interface' -import { modelsTable, settingsTable } from '../db/tables' +import { modelsSecretsTable, modelsTable, settingsTable } from '../db/tables' import { clearNullableColumns, nowIso } from '../lib/utils' import type { DrizzleQueryWithPromise, Model } from '@/types' import { getLastMessage } from './chat-messages' import { createDefaultModelProfile, deleteModelProfileForModel } from './model-profiles' +/** Select columns: all model columns + apiKey from the local-only secrets table. */ +const modelWithSecretColumns = { + ...getTableColumns(modelsTable), + apiKey: modelsSecretsTable.apiKey, +} + +/** Base query that LEFT JOINs models with their local-only secrets. */ +const selectModelsWithSecrets = (db: AnyDrizzleDatabase) => + db + .select(modelWithSecretColumns) + .from(modelsTable) + .leftJoin(modelsSecretsTable, eq(modelsTable.id, modelsSecretsTable.modelId)) + /** * Gets all models from the database (excluding soft-deleted) * Sorted with system models first, then alphabetically by name */ export const getAllModels = (db: AnyDrizzleDatabase) => { - const query = db - .select() - .from(modelsTable) + const query = selectModelsWithSecrets(db) .where(isNull(modelsTable.deletedAt)) .orderBy(desc(modelsTable.isSystem), modelsTable.name) @@ -29,9 +40,7 @@ export const getAllModels = (db: AnyDrizzleDatabase) => { * Sorted with system models first, then alphabetically by name */ export const getAvailableModels = (db: AnyDrizzleDatabase) => { - const query = db - .select() - .from(modelsTable) + const query = selectModelsWithSecrets(db) .where(and(eq(modelsTable.enabled, 1), isNull(modelsTable.deletedAt))) .orderBy(desc(modelsTable.isSystem), modelsTable.name) @@ -39,10 +48,7 @@ export const getAvailableModels = (db: AnyDrizzleDatabase) => { } export const getModelQuery = (db: AnyDrizzleDatabase, id: string) => { - const query = db - .select() - .from(modelsTable) - .where(and(eq(modelsTable.id, id), isNull(modelsTable.deletedAt))) + const query = selectModelsWithSecrets(db).where(and(eq(modelsTable.id, id), isNull(modelsTable.deletedAt))) return query as typeof query & DrizzleQueryWithPromise } @@ -53,9 +59,7 @@ export const getModelQuery = (db: AnyDrizzleDatabase, id: string) => { * Returns the selected model if it exists and is enabled; otherwise the system model. */ export const getSelectedModelQuery = (db: AnyDrizzleDatabase) => { - const query = db - .select(getTableColumns(modelsTable)) - .from(modelsTable) + const query = selectModelsWithSecrets(db) .leftJoin( settingsTable, and(eq(settingsTable.key, 'selected_model'), eq(settingsTable.value, modelsTable.id), eq(modelsTable.enabled, 1)), @@ -76,9 +80,7 @@ export const getModel = async (db: AnyDrizzleDatabase, id: string): Promise => { - const systemModel = await db - .select() - .from(modelsTable) + const systemModel = await selectModelsWithSecrets(db) .where(and(eq(modelsTable.isSystem, 1), isNull(modelsTable.deletedAt))) .orderBy(modelsTable.name) .get() @@ -133,16 +135,27 @@ export const getDefaultModelForThread = async ( */ export const updateModel = async (db: AnyDrizzleDatabase, id: string, updates: Partial): Promise => { // Don't allow updating defaultHash - it must be preserved for modification tracking - const { defaultHash, ...updateFields } = updates as Partial & { defaultHash?: string } - await db.update(modelsTable).set(updateFields).where(eq(modelsTable.id, id)) + const { defaultHash, apiKey, ...updateFields } = updates as Partial & { defaultHash?: string } + + if (Object.keys(updateFields).length > 0) { + await db.update(modelsTable).set(updateFields).where(eq(modelsTable.id, id)) + } + + if (apiKey !== undefined) { + await db.insert(modelsSecretsTable).values({ modelId: id, apiKey }).onConflictDoUpdate({ + target: modelsSecretsTable.modelId, + set: { apiKey }, + }) + } } /** * Reset a model to its default state */ export const resetModelToDefault = async (db: AnyDrizzleDatabase, id: string, defaultModel: Model): Promise => { - const { defaultHash, ...defaultFields } = defaultModel + const { defaultHash, apiKey, ...defaultFields } = defaultModel await db.update(modelsTable).set(defaultFields).where(eq(modelsTable.id, id)) + await db.delete(modelsSecretsTable).where(eq(modelsSecretsTable.modelId, id)) } /** @@ -157,6 +170,7 @@ export const deleteModel = async (db: AnyDrizzleDatabase, id: string): Promise { await deleteModelProfileForModel(tx, id) await deletePromptsForModel(tx, id) + await tx.delete(modelsSecretsTable).where(eq(modelsSecretsTable.modelId, id)) await tx .update(modelsTable) .set({ ...clearNullableColumns(modelsTable), deletedAt: nowIso() }) @@ -171,8 +185,12 @@ export const createModel = async ( db: AnyDrizzleDatabase, data: Partial & Pick, ): Promise => { + const { apiKey, ...modelData } = data await db.transaction(async (tx) => { - await tx.insert(modelsTable).values(data) + await tx.insert(modelsTable).values(modelData) + if (apiKey != null) { + await tx.insert(modelsSecretsTable).values({ modelId: data.id, apiKey }) + } await createDefaultModelProfile(tx, data.id) }) } diff --git a/src/db/encryption/config.ts b/src/db/encryption/config.ts index 0a6d66b1..1dca69d3 100644 --- a/src/db/encryption/config.ts +++ b/src/db/encryption/config.ts @@ -32,7 +32,7 @@ export const encryptedColumnsMap: Readonly> = chat_threads: ['title'], chat_messages: ['content', 'parts', 'cache', 'metadata'], tasks: ['item'], - models: ['name', 'model', 'url', 'api_key', 'vendor', 'description'], + models: ['name', 'model', 'url', 'vendor', 'description'], mcp_servers: ['name', 'url', 'command', 'args'], prompts: ['title', 'prompt'], triggers: ['trigger_time'], diff --git a/src/db/powersync/schema.ts b/src/db/powersync/schema.ts index 67a86629..de780a27 100644 --- a/src/db/powersync/schema.ts +++ b/src/db/powersync/schema.ts @@ -3,15 +3,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { PowerSyncTableName } from '@shared/powersync-tables' -import { DrizzleAppSchema } from '@powersync/drizzle-driver' +import { DrizzleAppSchema, type DrizzleTableWithPowerSyncOptions } from '@powersync/drizzle-driver' import * as tables from '../tables' /** - * Drizzle schema for PowerSync - keys are snake_case (table names). - * Type-checked: every PowerSyncTableName must have an entry. - * The driver uses the table's config name, not our keys; snake_case keeps types in sync with shared. + * Synced tables — type-checked against PowerSyncTableName. + * Keys are snake_case (table names). The driver uses the table's config name, not our keys. */ -export const drizzleSchema = { +const syncedTables = { settings: tables.settingsTable, chat_threads: tables.chatThreadsTable, chat_messages: tables.chatMessagesTable, @@ -25,6 +24,22 @@ export const drizzleSchema = { devices: tables.devicesTable, } satisfies Record +/** Local-only tables — created in SQLite but never synced via PowerSync. */ +const localOnlyTables = { + models_secrets: { + tableDefinition: tables.modelsSecretsTable, + options: { localOnly: true }, + } satisfies DrizzleTableWithPowerSyncOptions, +} + +/** + * Combined Drizzle schema for PowerSync AppSchema. + */ +export const drizzleSchema = { + ...syncedTables, + ...localOnlyTables, +} + /** * PowerSync AppSchema derived from Drizzle table definitions. */ diff --git a/src/db/relations.ts b/src/db/relations.ts index 0ef18f88..459b1bbc 100644 --- a/src/db/relations.ts +++ b/src/db/relations.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { relations } from 'drizzle-orm' -import { chatMessagesTable, chatThreadsTable, modelProfilesTable, modelsTable } from './tables' +import { chatMessagesTable, chatThreadsTable, modelProfilesTable, modelsSecretsTable, modelsTable } from './tables' export const chatThreadsRelations = relations(chatThreadsTable, ({ many }) => ({ messages: many(chatMessagesTable), @@ -34,6 +34,17 @@ export const modelsRelations = relations(modelsTable, ({ one, many }) => ({ fields: [modelsTable.id], references: [modelProfilesTable.modelId], }), + secret: one(modelsSecretsTable, { + fields: [modelsTable.id], + references: [modelsSecretsTable.modelId], + }), +})) + +export const modelsSecretsRelations = relations(modelsSecretsTable, ({ one }) => ({ + model: one(modelsTable, { + fields: [modelsSecretsTable.modelId], + references: [modelsTable.id], + }), })) export const modelProfilesRelations = relations(modelProfilesTable, ({ one }) => ({ diff --git a/src/db/tables.ts b/src/db/tables.ts index 774dd44a..9c73c78f 100644 --- a/src/db/tables.ts +++ b/src/db/tables.ts @@ -87,7 +87,6 @@ export const modelsTable = sqliteTable( name: text('name'), model: text('model'), url: text('url'), - apiKey: text('api_key'), isSystem: integer('is_system').default(0), enabled: integer('enabled').default(1), toolUsage: integer('tool_usage').default(1), @@ -108,6 +107,12 @@ export const modelsTable = sqliteTable( ], ) +/** Local-only table for model API keys. Never synced via PowerSync. */ +export const modelsSecretsTable = sqliteTable('models_secrets', { + modelId: text('id').primaryKey(), + apiKey: text('api_key'), +}) + export const mcpServersTable = sqliteTable( 'mcp_servers', { diff --git a/src/defaults/models.ts b/src/defaults/models.ts index 889c2989..e6e990b7 100644 --- a/src/defaults/models.ts +++ b/src/defaults/models.ts @@ -15,7 +15,6 @@ export const hashModel = (model: Model): string => { model.provider, model.model, model.url, - model.apiKey, model.isSystem, model.enabled, model.toolUsage, diff --git a/src/types.ts b/src/types.ts index 9133d12d..9c50cbfe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,6 +62,7 @@ export type ModelProfileRow = InferSelectModel // Application types - Row types with previously-required fields made non-null export type ChatMessage = WithRequired export type ChatThread = WithRequired +/** apiKey comes from LEFT JOIN with models_secrets in DAL queries, not from the models table. */ export type Model = WithRequired< ModelRow, | 'provider' @@ -72,7 +73,7 @@ export type Model = WithRequired< | 'isConfidential' | 'startWithReasoning' | 'supportsParallelToolCalls' -> +> & { apiKey: string | null } export type Mode = WithRequired export type Task = WithRequired export type McpServer = WithRequired From bd9966465fcdaf4374821b13cf2c94e116340561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Tue, 12 May 2026 08:34:36 -0300 Subject: [PATCH 2/9] feat: add missing API key warning indicator to model picker and list --- .../ui/model-selector/model-selector.test.ts | 3 +- .../ui/model-selector/model-selector.tsx | 67 +++++++++++++------ src/settings/models/index.tsx | 14 +++- src/settings/models/layout.tsx | 19 ++++-- 4 files changed, 74 insertions(+), 29 deletions(-) diff --git a/src/components/ui/model-selector/model-selector.test.ts b/src/components/ui/model-selector/model-selector.test.ts index d0b10ce3..d975bad1 100644 --- a/src/components/ui/model-selector/model-selector.test.ts +++ b/src/components/ui/model-selector/model-selector.test.ts @@ -10,13 +10,14 @@ import type { ChatThread } from '@/layout/sidebar/types' const makeModel = (overrides: Partial & { id: string; name: string }): Model => ({ model: 'test-model', - provider: 'test', + provider: 'thunderbolt', enabled: 1, toolUsage: 'auto', isConfidential: 0, startWithReasoning: 0, supportsParallelToolCalls: 1, isSystem: 1, + apiKey: null, ...overrides, }) as Model diff --git a/src/components/ui/model-selector/model-selector.tsx b/src/components/ui/model-selector/model-selector.tsx index d1899e94..de8a568d 100644 --- a/src/components/ui/model-selector/model-selector.tsx +++ b/src/components/ui/model-selector/model-selector.tsx @@ -4,11 +4,12 @@ import { Button } from '@/components/ui/button' import { SearchableMenu, type SearchableMenuGroup, type SearchableMenuItem } from '@/components/ui/searchable-menu' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useHaptics } from '@/hooks/use-haptics' import { cn } from '@/lib/utils' import type { ChatThread } from '@/layout/sidebar/types' import type { Model } from '@/types' -import { ChevronDown, Lock, Plus } from 'lucide-react' +import { AlertTriangle, ChevronDown, Lock, Plus } from 'lucide-react' import { useCallback, useMemo } from 'react' export type ModelSelectorProps = { @@ -25,6 +26,9 @@ type ModelItemData = { model: Model } +/** Models that are not Thunderbolt-hosted and have no local API key need configuration. */ +const needsApiKey = (model: Model) => model.provider !== 'thunderbolt' && !model.apiKey + const toMenuItem = (model: Model, isDisabled: boolean): SearchableMenuItem => ({ id: model.id, label: model.name, @@ -45,10 +49,11 @@ export const categorizeModels = ( const disabledStandard: SearchableMenuItem[] = [] for (const model of models) { - const isDisabled = chatThread ? chatThread.isEncrypted !== model.isConfidential : false + const isDisabledByEncryption = chatThread ? chatThread.isEncrypted !== model.isConfidential : false + const isDisabled = isDisabledByEncryption || needsApiKey(model) const item = toMenuItem(model, isDisabled) - if (isDisabled) { + if (isDisabledByEncryption) { if (model.isConfidential === 1) { disabledConfidential.push(item) } else { @@ -107,30 +112,52 @@ export const ModelSelector = ({ isOpen ? 'bg-secondary' : 'hover:bg-secondary/50', )} > - {selected?.data?.model.isConfidential === 1 && } + {selected?.data?.model && needsApiKey(selected.data.model) ? ( + + ) : selected?.data?.model.isConfidential === 1 ? ( + + ) : null} {selected?.label ?? 'Select Model'} ) - const renderItem = (item: SearchableMenuItem, isSelected: boolean) => ( -
-
-
- {item.label} - {item.icon} + const renderItem = (item: SearchableMenuItem, isSelected: boolean) => { + const model = item.data?.model + const missingKey = model ? needsApiKey(model) : false + + const content = ( +
+
+
+ {item.label} + {missingKey ? : item.icon} +
+ {item.description}
- {item.description}
-
- ) + ) + + if (missingKey) { + return ( + + + {content} + API key not configured + + + ) + } + + return content + } const footer = onAddModels ? ( + +
+ + + + + ) +} + export default function ModelsPage() { const db = useDatabase() const [state, dispatch] = useReducer(modelReducer, initialState) + const [editingModel, setEditingModel] = useState(null) const { isAddDialogOpen, deleteConfirmOpen, @@ -240,6 +380,20 @@ export default function ModelsPage() { }, }) + const editModelMutation = useMutation({ + mutationFn: async (values: z.infer & { id: string }) => { + const { id, ...fields } = values + await updateModel(db, id, { + ...fields, + apiKey: fields.apiKey || null, + url: fields.url || null, + }) + }, + onSuccess: () => { + setEditingModel(null) + }, + }) + const resetModelMutation = useMutation({ mutationFn: async (id: string) => { const defaultModel = defaultModels.find((m) => m.id === id) @@ -1027,59 +1181,23 @@ export default function ModelsPage() { - {isSystemModel ? ( - - - - - - -

System models can't be deleted

-
-
-
- ) : ( - - dispatch( - open - ? { type: 'OPEN_DELETE_CONFIRM', modelId: model.id } - : { type: 'CLOSE_DELETE_CONFIRM' }, - ) - } + + + setEditingModel(model)} + disabled={isSystemModel} > - - - - -
-
-

Remove Model

-

- Are you sure you want to remove this model? This action cannot be undone. -

-
-
- - -
-
-
-
- )} + + + dispatch({ type: 'OPEN_DELETE_CONFIRM', modelId: model.id })} + disabled={isSystemModel} + > + + +
@@ -1122,6 +1240,43 @@ export default function ModelsPage() { )} + + {/* Edit Model Modal */} + !open && setEditingModel(null)} + onSubmit={(values) => editModelMutation.mutate(values)} + isPending={editModelMutation.isPending} + /> + + {/* Delete Confirmation */} + !open && dispatch({ type: 'CLOSE_DELETE_CONFIRM' })} + > + + + Remove Model + + Are you sure you want to remove this model? This action cannot be undone. + + + + Cancel + { + if (deleteConfirmOpen) { + handleDeleteModel(deleteConfirmOpen) + } + }} + disabled={deleteModelMutation.isPending} + className="bg-destructive text-white hover:bg-destructive/90" + > + {deleteModelMutation.isPending ? 'Removing...' : 'Remove'} + + + + ) } From 42dce020ab9fb94973ba582768754335a87afdae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Wed, 13 May 2026 18:24:27 -0300 Subject: [PATCH 4/9] fix: skip secrets insert when updateModel receives null apiKey for new row --- src/dal/models.test.ts | 17 +++++++++++++++++ src/dal/models.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/dal/models.test.ts b/src/dal/models.test.ts index 646fc8f7..eac0817d 100644 --- a/src/dal/models.test.ts +++ b/src/dal/models.test.ts @@ -1090,6 +1090,23 @@ describe('Models DAL', () => { expect(model?.apiKey).toBeNull() }) + it('should not create a secret row when updating with null apiKey and no existing row', async () => { + const db = getDb() + const modelId = uuidv7() + + await createModel(db, { + id: modelId, + provider: 'openai', + name: 'No key model', + model: 'gpt-4', + }) + + await updateModel(db, modelId, { apiKey: null }) + + const secret = await db.select().from(modelsSecretsTable).where(eq(modelsSecretsTable.modelId, modelId)).get() + expect(secret).toBeUndefined() + }) + it('should upsert apiKey via updateModel', async () => { const db = getDb() const modelId = uuidv7() diff --git a/src/dal/models.ts b/src/dal/models.ts index 54b8fec2..59827421 100644 --- a/src/dal/models.ts +++ b/src/dal/models.ts @@ -145,7 +145,7 @@ export const updateModel = async (db: AnyDrizzleDatabase, id: string, updates: P const existing = await db.select().from(modelsSecretsTable).where(eq(modelsSecretsTable.modelId, id)).get() if (existing) { await db.update(modelsSecretsTable).set({ apiKey }).where(eq(modelsSecretsTable.modelId, id)) - } else { + } else if (apiKey != null) { await db.insert(modelsSecretsTable).values({ modelId: id, apiKey }) } } From 898326a6149f8adda97eb45e5d2e2fe4665dff48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 14 May 2026 07:19:58 -0300 Subject: [PATCH 5/9] fix: wrap updateModel and resetModelToDefault in transactions --- src/dal/models.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/dal/models.ts b/src/dal/models.ts index 59827421..e356b713 100644 --- a/src/dal/models.ts +++ b/src/dal/models.ts @@ -137,18 +137,20 @@ export const updateModel = async (db: AnyDrizzleDatabase, id: string, updates: P // Don't allow updating defaultHash - it must be preserved for modification tracking const { defaultHash, apiKey, ...updateFields } = updates as Partial & { defaultHash?: string } - if (Object.keys(updateFields).length > 0) { - await db.update(modelsTable).set(updateFields).where(eq(modelsTable.id, id)) - } + await db.transaction(async (tx) => { + if (Object.keys(updateFields).length > 0) { + await tx.update(modelsTable).set(updateFields).where(eq(modelsTable.id, id)) + } - if (apiKey !== undefined) { - const existing = await db.select().from(modelsSecretsTable).where(eq(modelsSecretsTable.modelId, id)).get() - if (existing) { - await db.update(modelsSecretsTable).set({ apiKey }).where(eq(modelsSecretsTable.modelId, id)) - } else if (apiKey != null) { - await db.insert(modelsSecretsTable).values({ modelId: id, apiKey }) + if (apiKey !== undefined) { + const existing = await tx.select().from(modelsSecretsTable).where(eq(modelsSecretsTable.modelId, id)).get() + if (existing) { + await tx.update(modelsSecretsTable).set({ apiKey }).where(eq(modelsSecretsTable.modelId, id)) + } else if (apiKey != null) { + await tx.insert(modelsSecretsTable).values({ modelId: id, apiKey }) + } } - } + }) } /** @@ -156,8 +158,10 @@ export const updateModel = async (db: AnyDrizzleDatabase, id: string, updates: P */ export const resetModelToDefault = async (db: AnyDrizzleDatabase, id: string, defaultModel: Model): Promise => { const { defaultHash, apiKey, ...defaultFields } = defaultModel - await db.update(modelsTable).set(defaultFields).where(eq(modelsTable.id, id)) - await db.delete(modelsSecretsTable).where(eq(modelsSecretsTable.modelId, id)) + await db.transaction(async (tx) => { + await tx.update(modelsTable).set(defaultFields).where(eq(modelsTable.id, id)) + await tx.delete(modelsSecretsTable).where(eq(modelsSecretsTable.modelId, id)) + }) } /** From 32a07940db984709de252dd8e82aea02426d1da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 14 May 2026 07:20:49 -0300 Subject: [PATCH 6/9] docs: explain UPSERT workaround for PowerSync local-only views --- src/dal/models.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/dal/models.ts b/src/dal/models.ts index e356b713..ecb9f9c5 100644 --- a/src/dal/models.ts +++ b/src/dal/models.ts @@ -142,6 +142,8 @@ export const updateModel = async (db: AnyDrizzleDatabase, id: string, updates: P await tx.update(modelsTable).set(updateFields).where(eq(modelsTable.id, id)) } + // PowerSync exposes local-only tables as SQLite views, which don't support + // INSERT...ON CONFLICT DO UPDATE. Emulate UPSERT with SELECT-then-INSERT/UPDATE. if (apiKey !== undefined) { const existing = await tx.select().from(modelsSecretsTable).where(eq(modelsSecretsTable.modelId, id)).get() if (existing) { From db496877ad18ff3824afcd66fad4cc6d07888f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 14 May 2026 07:43:27 -0300 Subject: [PATCH 7/9] fix: replace useEffect with keyed child to reset edit/add model forms --- src/settings/models/index.tsx | 207 ++++++++++++++++++---------------- 1 file changed, 109 insertions(+), 98 deletions(-) diff --git a/src/settings/models/index.tsx b/src/settings/models/index.tsx index 1539ee72..2e498b04 100644 --- a/src/settings/models/index.tsx +++ b/src/settings/models/index.tsx @@ -204,128 +204,137 @@ const editFormSchema = z.object({ apiKey: z.string().optional(), }) -const EditModelModal = ({ +const EditModelForm = ({ model, - onOpenChange, + onCancel, onSubmit, isPending, }: { - model: Model | null - onOpenChange: (open: boolean) => void + model: Model + onCancel: () => void onSubmit: (values: z.infer & { id: string }) => void isPending: boolean }) => { const form = useForm>({ resolver: zodResolver(editFormSchema), defaultValues: { - name: model?.name || '', - model: model?.model || '', - url: model?.url || '', - apiKey: model?.apiKey || '', + name: model.name || '', + model: model.model || '', + url: model.url || '', + apiKey: model.apiKey || '', }, }) - useEffect(() => { - if (model) { - form.reset({ - name: model.name || '', - model: model.model || '', - url: model.url || '', - apiKey: model.apiKey || '', - }) - } - }, [model, form]) - const handleSubmit = (values: z.infer) => { - if (model) { - onSubmit({ ...values, id: model.id }) - } + onSubmit({ ...values, id: model.id }) } return ( - - - - Edit Model - Edit model configuration - -
- - ( - - Name - - - - - - )} - /> - - ( - - Model - - - - - - )} - /> - - {model?.provider === 'custom' && ( - ( - - URL - - - - - - )} - /> + + + ( + + Name + + + + + + )} + /> + + ( + + Model + + + + + + )} + /> + + {model.provider === 'custom' && ( + ( + + URL + + + + + )} + /> + )} - {model?.provider !== 'thunderbolt' && ( - ( - - API Key - - - - - - )} - /> + {model.provider !== 'thunderbolt' && ( + ( + + API Key + + + + + )} + /> + )} -
- - -
- - -
-
+
+ + +
+ + ) } +const EditModelModal = ({ + model, + onOpenChange, + onSubmit, + isPending, +}: { + model: Model | null + onOpenChange: (open: boolean) => void + onSubmit: (values: z.infer & { id: string }) => void + isPending: boolean +}) => ( + + + + Edit Model + Edit model configuration + + {model && ( + onOpenChange(false)} + onSubmit={onSubmit} + isPending={isPending} + /> + )} + + +) + export default function ModelsPage() { const db = useDatabase() const [state, dispatch] = useReducer(modelReducer, initialState) @@ -367,6 +376,8 @@ export default function ModelsPage() { }) }, onSuccess: () => { + form.reset() + form.clearErrors() dispatch({ type: 'CLOSE_DIALOG' }) }, }) From 9d366d031feec6b0594f98a764f627fa5e53e2bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 14 May 2026 08:07:48 -0300 Subject: [PATCH 8/9] chore: remove unrouted models layout/new/detail files --- src/settings/models/detail.test.tsx | 63 ------- src/settings/models/detail.tsx | 260 ---------------------------- src/settings/models/layout.tsx | 81 --------- src/settings/models/new.tsx | 201 --------------------- 4 files changed, 605 deletions(-) delete mode 100644 src/settings/models/detail.test.tsx delete mode 100644 src/settings/models/detail.tsx delete mode 100644 src/settings/models/layout.tsx delete mode 100644 src/settings/models/new.tsx diff --git a/src/settings/models/detail.test.tsx b/src/settings/models/detail.test.tsx deleted file mode 100644 index 8e81ed4c..00000000 --- a/src/settings/models/detail.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import { createModel, updateModel } from '@/dal' -import { resetTestDatabase, setupTestDatabase, teardownTestDatabase } from '@/dal/test-utils' -import { getDb } from '@/db/database' -import { renderWithReactivity, waitForElement } from '@/test-utils/powersync-reactivity-test' -import { getClock } from '@/testing-library' -import '@testing-library/jest-dom' -import { act, cleanup, screen } from '@testing-library/react' -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test' -import { v7 as uuidv7 } from 'uuid' -import ModelDetailPage from './detail' - -describe('ModelDetailPage reactivity', () => { - beforeAll(async () => { - await setupTestDatabase() - }) - - afterAll(async () => { - await teardownTestDatabase() - }) - - beforeEach(async () => { - await resetTestDatabase() - }) - - afterEach(() => { - cleanup() - }) - - it('updates when models table changes', async () => { - const db = getDb() - const modelId = uuidv7() - await createModel(db, { - id: modelId, - provider: 'openai', - name: 'Original Name', - model: 'gpt-4', - isSystem: 0, - enabled: 1, - }) - - const { triggerChange } = renderWithReactivity(, { - route: `/settings/models/${modelId}`, - routePath: '/settings/models/:modelId', - tables: ['models'], - }) - - await waitForElement(() => screen.queryByDisplayValue('Original Name')) - expect(screen.getByDisplayValue('Original Name')).toBeInTheDocument() - - await updateModel(db, modelId, { name: 'Updated Name' }) - triggerChange(['models']) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(screen.getByDisplayValue('Updated Name')).toBeInTheDocument() - }) -}) diff --git a/src/settings/models/detail.tsx b/src/settings/models/detail.tsx deleted file mode 100644 index 20c7efce..00000000 --- a/src/settings/models/detail.tsx +++ /dev/null @@ -1,260 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import { zodResolver } from '@hookform/resolvers/zod' -import { useMutation } from '@tanstack/react-query' -import { useQuery } from '@powersync/tanstack-react-query' -import { toCompilableQuery } from '@powersync/drizzle-driver' -import { useEffect, useState } from 'react' -import { useForm } from 'react-hook-form' -import { useParams } from 'react-router' -import { z } from 'zod' - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' -import { Input } from '@/components/ui/input' -import { useDatabase } from '@/contexts' -import { deleteModel, updateModel, getModelQuery } from '@/dal' -import type { Model } from '@/types' -import { Trash2 } from 'lucide-react' - -const formSchema = z - .object({ - provider: z.enum(['thunderbolt', 'anthropic', 'openai', 'custom', 'openrouter']), - name: z.string().min(1, { message: 'Name is required.' }), - model: z.string().min(1, { message: 'Model name is required.' }), - url: z.string().optional(), - apiKey: z.string().optional(), - }) - .refine( - (data) => { - if (data.provider === 'custom') { - return data.url !== undefined && data.url.length > 0 - } - return true - }, - { - message: 'URL is required for Custom providers', - path: ['url'], - }, - ) - .refine( - (data) => { - if (data.provider === 'custom') { - return true // API key is optional for custom provider - } - if (data.provider === 'thunderbolt') { - return true // API key not required for thunderbolt - } - return data.apiKey !== undefined && data.apiKey.length > 0 - }, - { - message: 'API Key is required for this provider', - path: ['apiKey'], - }, - ) - -export default function ModelDetailPage() { - const db = useDatabase() - const { modelId } = useParams() - const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [showSaved, setShowSaved] = useState(false) - - const { data = [], isLoading } = useQuery({ - queryKey: ['models', modelId], - query: toCompilableQuery(getModelQuery(db, modelId!)), - enabled: !!modelId, - }) - - const model = data?.[0] - - const updateModelMutation = useMutation({ - mutationFn: async (model: Partial & { id: string }) => { - const { id, ...updates } = model - await updateModel(db, id, updates) - }, - onSuccess: () => { - setShowSaved(true) - setTimeout(() => setShowSaved(false), 2000) - }, - }) - - const deleteModelMutation = useMutation({ - mutationFn: (id: string) => deleteModel(db, id), - onSuccess: () => { - setShowDeleteDialog(false) - }, - }) - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - provider: model?.provider || 'thunderbolt', - name: model?.name || '', - model: model?.model || '', - url: model?.url || '', - apiKey: model?.apiKey || '', - }, - }) - - // Update form values when model changes - useEffect(() => { - if (model) { - form.reset({ - provider: model.provider || 'thunderbolt', - name: model.name || '', - model: model.model || '', - url: model.url || '', - apiKey: model.apiKey || '', - }) - } - }, [model, form]) - - const onSubmit = (values: z.infer) => { - if (modelId) { - updateModelMutation.mutate({ - id: modelId, - ...values, - apiKey: values.apiKey || null, - url: values.url || null, - }) - } - } - - const handleDeleteModel = () => { - if (modelId) { - deleteModelMutation.mutate(modelId) - } - } - - if (isLoading || !model) { - return
Loading...
- } - - return ( - <> - - -
- - {model.isSystem !== 1 && ( - ( - - Name - - - - - - )} - /> - )} - - {model.isSystem !== 1 && ( - ( - - Model - - - - - - )} - /> - )} - - {model.isSystem !== 1 && form.watch('provider') === 'custom' && ( - ( - - URL - - - - - - )} - /> - )} - - ( - - API Key - - - - - - )} - /> - - - - {model.isSystem === 0 && ( - - )} - - -
-
- - - - - Are you sure? - - This will permanently delete the model "{model.model}". This action cannot be undone. - - - - Cancel - - Delete - - - - - - ) -} diff --git a/src/settings/models/layout.tsx b/src/settings/models/layout.tsx deleted file mode 100644 index 79826e42..00000000 --- a/src/settings/models/layout.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import { Button } from '@/components/ui/button' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { AlertTriangle, Plus } from 'lucide-react' -import { Navigate, Outlet, useNavigate, useParams } from 'react-router' -import { useDatabase } from '@/contexts' -import { getAllModels } from '@/dal' -import { useQuery } from '@powersync/tanstack-react-query' -import { toCompilableQuery } from '@powersync/drizzle-driver' - -export default function ModelsLayout() { - const db = useDatabase() - const navigate = useNavigate() - const { modelId } = useParams() - - const { data: models = [] } = useQuery({ - queryKey: ['models'], - query: toCompilableQuery(getAllModels(db)), - }) - - // Find the currently selected model - const currentModel = models.find((model) => model.id === modelId) - - // Check if we're on the "new" route - const isNewRoute = window.location.pathname.endsWith('/models/new') - - // Redirect to first model if no model is selected and we're not on the new route - if (!modelId && models.length > 0 && !isNewRoute) { - return - } - - const handleModelSelect = (value: string) => { - navigate(`/settings/models/${value}`) - } - - return ( -
-
-

Models

- -
- - - - -
- ) -} diff --git a/src/settings/models/new.tsx b/src/settings/models/new.tsx deleted file mode 100644 index 7b70ff68..00000000 --- a/src/settings/models/new.tsx +++ /dev/null @@ -1,201 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import { zodResolver } from '@hookform/resolvers/zod' -import { useMutation } from '@tanstack/react-query' -import { useForm } from 'react-hook-form' -import { useNavigate } from 'react-router' -import { v7 as uuidv7 } from 'uuid' -import { z } from 'zod' - -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' -import { Input } from '@/components/ui/input' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { useDatabase } from '@/contexts' -import { createModel } from '@/dal' -import type { Model } from '@/types' - -const formSchema = z - .object({ - provider: z.enum(['thunderbolt', 'openai', 'custom', 'openrouter']), - name: z.string().min(1, { message: 'Name is required.' }), - model: z.string().min(1, { message: 'Model name is required.' }), - url: z.string().optional(), - apiKey: z.string().optional(), - }) - .refine( - (data) => { - if (data.provider === 'custom') { - return data.url !== undefined && data.url.length > 0 - } - return true - }, - { - message: 'URL is required for Custom providers', - path: ['url'], - }, - ) - .refine( - (data) => { - if (data.provider === 'custom') { - return true // API key is optional for custom provider - } - if (data.provider === 'thunderbolt') { - return true // API key optional for thunderbolt - } - return data.apiKey !== undefined && data.apiKey.length > 0 - }, - { - message: 'API Key is required for this provider', - path: ['apiKey'], - }, - ) - -export default function NewModelPage() { - const db = useDatabase() - const navigate = useNavigate() - - const createModelMutation = useMutation({ - mutationFn: async (model: Omit) => { - const id = uuidv7() - await createModel(db, { - id, - ...model, - }) - return id - }, - onSuccess: (id) => { - navigate(`/settings/models/${id}`) - }, - }) - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - provider: 'thunderbolt', - name: '', - model: '', - url: '', - apiKey: '', - }, - }) - - const onSubmit = (values: z.infer) => { - createModelMutation.mutate({ - ...values, - apiKey: values.apiKey || null, - url: values.url || null, - isSystem: 0, - enabled: 1, - toolUsage: 1, - isConfidential: 0, - startWithReasoning: 0, - supportsParallelToolCalls: 1, - contextWindow: null, - deletedAt: null, - defaultHash: null, // User-created, not based on a default - vendor: null, - description: null, - userId: null, - }) - } - - return ( - - -
- - ( - - Provider - - - - - - )} - /> - - ( - - Name - - - - - - )} - /> - - ( - - Model - - - - - - )} - /> - - {form.watch('provider') === 'custom' && ( - ( - - URL - - - - - - )} - /> - )} - - ( - - API Key - - - - - - )} - /> - - - - -
-
- ) -} From 1ea5cd15528758922d99335d90f5e30bc83814cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 14 May 2026 12:07:55 -0300 Subject: [PATCH 9/9] refactor: reuse needsApiKey helper in settings models page --- src/components/ui/model-selector/model-selector.tsx | 2 +- src/settings/models/index.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/ui/model-selector/model-selector.tsx b/src/components/ui/model-selector/model-selector.tsx index de8a568d..3f279b3d 100644 --- a/src/components/ui/model-selector/model-selector.tsx +++ b/src/components/ui/model-selector/model-selector.tsx @@ -27,7 +27,7 @@ type ModelItemData = { } /** Models that are not Thunderbolt-hosted and have no local API key need configuration. */ -const needsApiKey = (model: Model) => model.provider !== 'thunderbolt' && !model.apiKey +export const needsApiKey = (model: Model) => model.provider !== 'thunderbolt' && !model.apiKey const toMenuItem = (model: Model, isDisabled: boolean): SearchableMenuItem => ({ id: model.id, diff --git a/src/settings/models/index.tsx b/src/settings/models/index.tsx index 2e498b04..b3220542 100644 --- a/src/settings/models/index.tsx +++ b/src/settings/models/index.tsx @@ -19,6 +19,7 @@ import { ButtonGroup, ButtonGroupItem } from '@/components/ui/button-group' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' import { Combobox, type ComboboxItem } from '@/components/ui/combobox' +import { needsApiKey } from '@/components/ui/model-selector/model-selector' import { Dialog, DialogTrigger } from '@/components/ui/dialog' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { Input } from '@/components/ui/input' @@ -1146,7 +1147,7 @@ export default function ModelsPage() { )} - {model.provider !== 'thunderbolt' && !model.apiKey && ( + {needsApiKey(model) && (