diff --git a/apps/server/.env b/apps/server/.env index 77dd9b6dc..3d8b381ee 100644 --- a/apps/server/.env +++ b/apps/server/.env @@ -10,9 +10,7 @@ AUTH_GITHUB_CLIENT_SECRET="change-me" STRIPE_SECRET_KEY="change-me" STRIPE_WEBHOOK_SECRET="change-me" -BACKEND_LLM_API_KEY="change-me" -BACKEND_LLM_BASE_URL="change-me" - CLIENT_URL="change-me" +API_SERVER_URL="change-me" # OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" diff --git a/apps/server/drizzle/0003_old_titania.sql b/apps/server/drizzle/0003_old_titania.sql new file mode 100644 index 000000000..d33fd21e0 --- /dev/null +++ b/apps/server/drizzle/0003_old_titania.sql @@ -0,0 +1,14 @@ +CREATE TABLE "llm_request_log" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "model" text NOT NULL, + "status" integer NOT NULL, + "duration_ms" integer NOT NULL, + "flux_consumed" integer NOT NULL, + "prompt_tokens" integer, + "completion_tokens" integer, + "settled" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "llm_request_log" ADD CONSTRAINT "llm_request_log_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0003_snapshot.json b/apps/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 000000000..d419cd9e7 --- /dev/null +++ b/apps/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2130 @@ +{ + "id": "7c979ba8-a696-484d-b97a-9ddabe5c9aec", + "prevId": "c1d089d5-904c-4404-8f43-d270d0bc42e6", + "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 + } + }, + "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": {} + } + }, + "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.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 + }, + "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.avatar_model": { + "name": "avatar_model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "avatar_model_character_id_characters_id_fk": { + "name": "avatar_model_character_id_characters_id_fk", + "tableFrom": "avatar_model", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.characters": { + "name": "characters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_role": { + "name": "creator_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_credit": { + "name": "price_credit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bookmarks_count": { + "name": "bookmarks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "interactions_count": { + "name": "interactions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forks_count": { + "name": "forks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "characters_creator_id_user_id_fk": { + "name": "characters_creator_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "characters_owner_id_user_id_fk": { + "name": "characters_owner_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_capabilities": { + "name": "character_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_capabilities_character_id_characters_id_fk": { + "name": "character_capabilities_character_id_characters_id_fk", + "tableFrom": "character_capabilities", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_covers": { + "name": "character_covers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "foreground_url": { + "name": "foreground_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "background_url": { + "name": "background_url", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_covers_character_id_characters_id_fk": { + "name": "character_covers_character_id_characters_id_fk", + "tableFrom": "character_covers", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_i18n": { + "name": "character_i18n", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_i18n_character_id_characters_id_fk": { + "name": "character_i18n_character_id_characters_id_fk", + "tableFrom": "character_i18n", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_prompts": { + "name": "character_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_prompts_character_id_characters_id_fk": { + "name": "character_prompts_character_id_characters_id_fk", + "tableFrom": "character_prompts", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_members": { + "name": "chat_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_type": { + "name": "member_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_members_chat_id_chats_id_fk": { + "name": "chat_members_chat_id_chats_id_fk", + "tableFrom": "chat_members", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_ids": { + "name": "media_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "sticker_ids": { + "name": "sticker_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "reply_message_id": { + "name": "reply_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forward_from_message_id": { + "name": "forward_from_message_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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sticker_packs": { + "name": "sticker_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stickers": { + "name": "stickers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_flux": { + "name": "user_flux", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "flux": { + "name": "flux", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_flux_user_id_user_id_fk": { + "name": "user_flux_user_id_user_id_fk", + "tableFrom": "user_flux", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.llm_request_log": { + "name": "llm_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "flux_consumed": { + "name": "flux_consumed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "settled": { + "name": "settled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "llm_request_log_user_id_user_id_fk": { + "name": "llm_request_log_user_id_user_id_fk", + "tableFrom": "llm_request_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_provider_configs": { + "name": "system_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_provider_configs": { + "name": "user_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_provider_configs_owner_id_user_id_fk": { + "name": "user_provider_configs_owner_id_user_id_fk", + "tableFrom": "user_provider_configs", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_checkout_session": { + "name": "stripe_checkout_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_session_id": { + "name": "stripe_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_total": { + "name": "amount_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success_url": { + "name": "success_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_url": { + "name": "cancel_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "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": { + "stripe_checkout_session_user_id_user_id_fk": { + "name": "stripe_checkout_session_user_id_user_id_fk", + "tableFrom": "stripe_checkout_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_checkout_session_stripe_session_id_unique": { + "name": "stripe_checkout_session_stripe_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_customer": { + "name": "stripe_customer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "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": { + "stripe_customer_user_id_user_id_fk": { + "name": "stripe_customer_user_id_user_id_fk", + "tableFrom": "stripe_customer", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_customer_stripe_customer_id_unique": { + "name": "stripe_customer_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_invoice": { + "name": "stripe_invoice", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_due": { + "name": "amount_due", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "amount_paid": { + "name": "amount_paid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_url": { + "name": "invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_pdf": { + "name": "invoice_pdf", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "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": { + "stripe_invoice_user_id_user_id_fk": { + "name": "stripe_invoice_user_id_user_id_fk", + "tableFrom": "stripe_invoice", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_invoice_stripe_invoice_id_unique": { + "name": "stripe_invoice_stripe_invoice_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_invoice_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_subscription": { + "name": "stripe_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "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": { + "stripe_subscription_user_id_user_id_fk": { + "name": "stripe_subscription_user_id_user_id_fk", + "tableFrom": "stripe_subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_subscription_stripe_subscription_id_unique": { + "name": "stripe_subscription_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_bookmarks": { + "name": "user_character_bookmarks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_bookmarks_user_id_user_id_fk": { + "name": "user_character_bookmarks_user_id_user_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_character_bookmarks_character_id_characters_id_fk": { + "name": "user_character_bookmarks_character_id_characters_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_bookmarks_user_id_character_id_pk": { + "name": "user_character_bookmarks_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_likes": { + "name": "user_character_likes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_likes_user_id_user_id_fk": { + "name": "user_character_likes_user_id_user_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_character_likes_character_id_characters_id_fk": { + "name": "user_character_likes_character_id_characters_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_likes_user_id_character_id_pk": { + "name": "user_character_likes_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index c1c0f77f5..62cbcc534 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1772634952890, "tag": "0002_mean_tigra", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1773229668722, + "tag": "0003_old_titania", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index 73b474a50..d5ec687af 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -8,6 +8,7 @@ "auth:generate": "pnpm run apply:env -- better-auth generate --config src/scripts/auth.ts --output src/schemas/accounts.ts -y", "dev": "pnpm run apply:env -- tsx --watch src/app.ts", "start": "tsx src/app.ts", + "typecheck": "tsc --noEmit", "db:generate": "drizzle-kit generate", "db:push": "pnpm run apply:env -- drizzle-kit push" }, diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index c8a3c99e9..7e084fae8 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -28,7 +28,9 @@ import { createCharacterService } from './services/characters' import { createChatService } from './services/chats' import { createConfigKVService } from './services/config-kv' import { createFluxService } from './services/flux' +import { createFluxWriteBack } from './services/flux-write-back' import { createProviderService } from './services/providers' +import { createRequestLogService } from './services/request-log' import { createStripeService } from './services/stripe' import { ApiError, createInternalError } from './utils/error' import { getTrustedOrigin } from './utils/origin' @@ -39,6 +41,7 @@ type ChatService = ReturnType type ProviderService = ReturnType type FluxService = ReturnType type ConfigKVService = ReturnType +type RequestLogService = ReturnType type StripeDBService = ReturnType type OtelMetrics = ReturnType @@ -49,6 +52,7 @@ interface AppDeps { chatService: ChatService providerService: ProviderService fluxService: FluxService + requestLogService: RequestLogService stripeService: StripeDBService configKV: ConfigKVService env: Env @@ -61,6 +65,7 @@ function buildApp({ chatService, providerService, fluxService, + requestLogService, stripeService, configKV, env, @@ -133,7 +138,7 @@ function buildApp({ /** * V1 routes for official provider. */ - .route('/v1', createV1CompletionsRoutes(fluxService, configKV, env)) + .route('/api/v1', createV1CompletionsRoutes(fluxService, configKV, requestLogService, otel)) /** * Flux routes. @@ -220,8 +225,26 @@ async function createApp() { }) const fluxService = injeca.provide('services:flux', { - dependsOn: { db, configKV }, - build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.configKV), + dependsOn: { db, redis, configKV }, + build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.redis, dependsOn.configKV), + }) + + const requestLogService = injeca.provide('services:requestLog', { + dependsOn: { db }, + build: ({ dependsOn }) => createRequestLogService(dependsOn.db), + }) + + const fluxWriteBack = injeca.provide('services:fluxWriteBack', { + dependsOn: { db, lifecycle }, + build: ({ dependsOn }) => { + const wb = createFluxWriteBack(dependsOn.db) + wb.start() + dependsOn.lifecycle.appHooks.onStop(async () => { + wb.stop() + await wb.flush() + }) + return wb + }, }) await injeca.start() @@ -231,10 +254,12 @@ async function createApp() { chatService, providerService, fluxService, + requestLogService, stripeService, configKV, - otel, env: parsedEnv, + otel, + fluxWriteBack, }) const app = buildApp({ auth: resolved.auth, @@ -242,15 +267,20 @@ async function createApp() { chatService: resolved.chatService, providerService: resolved.providerService, fluxService: resolved.fluxService, + requestLogService: resolved.requestLogService, stripeService: resolved.stripeService, configKV: resolved.configKV, env: resolved.env, otel: resolved.otel, }) - logger.withFields({ port: 3000 }).log('Server started') + logger.withFields({ hostname: resolved.env.HOST, port: resolved.env.PORT }).log('Server started') - return app + return { + ...app, + port: Number(resolved.env.PORT), + hostname: resolved.env.HOST, + } satisfies Parameters[0] } // eslint-disable-next-line antfu/no-top-level-await diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index 74668d279..de7a95f64 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -7,6 +7,9 @@ import { injeca } from 'injeca' import { nonEmpty, object, optional, parse, pipe, string } from 'valibot' const EnvSchema = object({ + HOST: optional(string(), '0.0.0.0'), + PORT: optional(string(), '3000'), + API_SERVER_URL: optional(string(), 'http://localhost:3000'), CLIENT_URL: optional(string(), 'https://airi.moerui.ai'), @@ -21,9 +24,6 @@ const EnvSchema = object({ STRIPE_SECRET_KEY: optional(string()), STRIPE_WEBHOOK_SECRET: optional(string()), - BACKEND_LLM_BASE_URL: optional(string()), - BACKEND_LLM_API_KEY: optional(string()), - // OpenTelemetry OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'), OTEL_SERVICE_NAME: optional(string(), 'server'), diff --git a/apps/server/src/libs/otel.ts b/apps/server/src/libs/otel.ts index db4a59f01..7cccb496d 100644 --- a/apps/server/src/libs/otel.ts +++ b/apps/server/src/libs/otel.ts @@ -141,6 +141,28 @@ export function initOtel(env: Env) { description: 'Number of Stripe webhook events processed', }) + // LLM / Gateway metrics + const llmRequestDuration = meter.createHistogram('llm.request.duration', { + description: 'LLM gateway request duration in milliseconds', + unit: 'ms', + }) + + const llmRequestCount = meter.createCounter('llm.request.count', { + description: 'Number of LLM gateway requests', + }) + + const llmTokensPrompt = meter.createCounter('llm.tokens.prompt', { + description: 'Total prompt tokens consumed', + }) + + const llmTokensCompletion = meter.createCounter('llm.tokens.completion', { + description: 'Total completion tokens consumed', + }) + + const fluxConsumed = meter.createCounter('flux.consumed', { + description: 'Total flux consumed', + }) + // Graceful shutdown const shutdown = async () => { try { @@ -162,6 +184,11 @@ export function initOtel(env: Env) { authAttempts, authFailures, stripeEvents, + llmRequestDuration, + llmRequestCount, + llmTokensPrompt, + llmTokensCompletion, + fluxConsumed, shutdown, } diff --git a/apps/server/src/middlewares/config-guard.ts b/apps/server/src/middlewares/config-guard.ts index d9f55935e..10928181a 100644 --- a/apps/server/src/middlewares/config-guard.ts +++ b/apps/server/src/middlewares/config-guard.ts @@ -11,7 +11,7 @@ import { createServiceUnavailableError } from '../utils/error' */ export function configGuard( configKV: ConfigKVService, - keys: Parameters[0][], + keys: Parameters[0][], message = 'Service is not available yet', ): MiddlewareHandler { return async (_c, next) => { diff --git a/apps/server/src/routes/__test__/v1completions.test.ts b/apps/server/src/routes/__test__/v1completions.test.ts new file mode 100644 index 000000000..e8e910c0d --- /dev/null +++ b/apps/server/src/routes/__test__/v1completions.test.ts @@ -0,0 +1,376 @@ +import type { ConfigKVService } from '../../services/config-kv' +import type { FluxService } from '../../services/flux' +import type { RequestLogService } from '../../services/request-log' +import type { HonoEnv } from '../../types/hono' + +import { Hono } from 'hono' +import { afterAll, describe, expect, it, vi } from 'vitest' + +import { ApiError } from '../../utils/error' +import { createV1CompletionsRoutes } from '../v1completions' + +// --- Mock helpers --- + +function createMockFluxService(flux = 100): FluxService { + return { + getFlux: vi.fn(async () => ({ userId: 'user-1', flux })), + consumeFlux: vi.fn(async (_userId: string, amount: number) => ({ userId: 'user-1', flux: flux - amount })), + addFlux: vi.fn(async (_userId: string, amount: number) => ({ userId: 'user-1', flux: flux + amount })), + updateStripeCustomerId: vi.fn(), + } as any +} + +function createMockConfigKV(overrides: Record = {}): ConfigKVService { + const defaults: Record = { + FLUX_PER_REQUEST: 1, + FLUX_PER_REQUEST_TTS: 1, + FLUX_PER_REQUEST_ASR: 1, + GATEWAY_BASE_URL: 'http://mock-gateway/', + DEFAULT_CHAT_MODEL: 'openai/gpt-5-mini', + ...overrides, + } + return { + getOrThrow: vi.fn(async (key: string) => { + if (defaults[key] === undefined) + throw new Error(`Config key "${key}" is not set`) + return defaults[key] + }), + getOptional: vi.fn(async (key: string) => defaults[key] ?? null), + get: vi.fn(async (key: string) => defaults[key]), + set: vi.fn(), + } as any +} + +function createMockRequestLogService(): RequestLogService { + return { + logRequest: vi.fn(async () => {}), + } as any +} + +function createTestApp( + fluxService: FluxService, + configKV: ConfigKVService, + requestLogService: RequestLogService, +) { + const routes = createV1CompletionsRoutes(fluxService, configKV, requestLogService, null) + const app = new Hono() + + app.onError((err, c) => { + if (err instanceof ApiError) { + return c.json({ + error: err.errorCode, + message: err.message, + details: err.details, + }, err.statusCode) + } + return c.json({ error: 'Internal Server Error', message: err.message }, 500) + }) + + // Inject user from env (simulates sessionMiddleware) + app.use('*', async (c, next) => { + const user = (c.env as any)?.user + if (user) { + c.set('user', user) + } + await next() + }) + + app.route('/api/v1', routes) + return app +} + +const testUser = { id: 'user-1', name: 'Test User', email: 'test@example.com' } + +// --- Tests --- + +describe('v1CompletionsRoutes', () => { + const originalFetch = globalThis.fetch + + afterAll(() => { + globalThis.fetch = originalFetch + }) + + describe('pOST /api/v1/chat/completions', () => { + it('should return 401 when unauthenticated', async () => { + const app = createTestApp( + createMockFluxService(), + createMockConfigKV(), + createMockRequestLogService(), + ) + + const res = await app.request('/api/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: 'hi' }] }), + }) + expect(res.status).toBe(401) + }) + + it('should return 402 when flux is insufficient', async () => { + const app = createTestApp( + createMockFluxService(0), + createMockConfigKV(), + createMockRequestLogService(), + ) + + const res = await app.fetch( + new Request('http://localhost/api/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: 'hi' }] }), + }), + { user: testUser } as any, + ) + expect(res.status).toBe(402) + }) + + it('should proxy upstream response on success', async () => { + const upstreamBody = JSON.stringify({ id: 'chatcmpl-1', choices: [{ message: { content: 'hello' } }] }) + globalThis.fetch = vi.fn(async () => new Response(upstreamBody, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + const fluxService = createMockFluxService(100) + const configKV = createMockConfigKV({ GATEWAY_BASE_URL: 'http://mock-gateway/' }) + const requestLogService = createMockRequestLogService() + const app = createTestApp(fluxService, configKV, requestLogService) + + const res = await app.fetch( + new Request('http://localhost/api/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: 'hi' }] }), + }), + { user: testUser } as any, + ) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data.id).toBe('chatcmpl-1') + + // Verify flux was consumed + expect(fluxService.consumeFlux).toHaveBeenCalledWith('user-1', 1) + + // Verify upstream was called with correct URL and resolved model + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://mock-gateway/chat/completions', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"model":"openai/gpt-5-mini"'), + }), + ) + }) + + it('should resolve "auto" model to DEFAULT_CHAT_MODEL from config', async () => { + globalThis.fetch = vi.fn(async () => new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + const app = createTestApp( + createMockFluxService(), + createMockConfigKV({ DEFAULT_CHAT_MODEL: 'anthropic/claude-sonnet' }), + createMockRequestLogService(), + ) + + await app.fetch( + new Request('http://localhost/api/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'auto', messages: [] }), + }), + { user: testUser } as any, + ) + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://mock-gateway/chat/completions', + expect.objectContaining({ + body: expect.stringContaining('"model":"anthropic/claude-sonnet"'), + }), + ) + }) + + it('should pass through non-auto model as-is', async () => { + globalThis.fetch = vi.fn(async () => new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService()) + + await app.fetch( + new Request('http://localhost/api/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'openai/gpt-5-mini', messages: [] }), + }), + { user: testUser } as any, + ) + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://mock-gateway/chat/completions', + expect.objectContaining({ + body: expect.stringContaining('"model":"openai/gpt-5-mini"'), + }), + ) + }) + + it('should not charge flux when upstream returns error', async () => { + globalThis.fetch = vi.fn(async () => new Response('{"error":"bad"}', { + status: 500, + headers: { 'Content-Type': 'application/json' }, + })) + + const fluxService = createMockFluxService(100) + const app = createTestApp(fluxService, createMockConfigKV(), createMockRequestLogService()) + + const res = await app.fetch( + new Request('http://localhost/api/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'auto', messages: [] }), + }), + { user: testUser } as any, + ) + + expect(res.status).toBe(500) + // Post-billing: no charge on failed requests, no refund needed + expect(fluxService.consumeFlux).not.toHaveBeenCalled() + expect(fluxService.addFlux).not.toHaveBeenCalled() + }) + + it('should return 503 when config keys are missing', async () => { + const configKV = createMockConfigKV() + // Override getOptional to return null for required keys + configKV.getOptional = vi.fn(async () => null) + + const app = createTestApp(createMockFluxService(), configKV, createMockRequestLogService()) + + const res = await app.fetch( + new Request('http://localhost/api/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'auto', messages: [] }), + }), + { user: testUser } as any, + ) + expect(res.status).toBe(503) + }) + + it('should log the request', async () => { + globalThis.fetch = vi.fn(async () => new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + const requestLogService = createMockRequestLogService() + const app = createTestApp(createMockFluxService(), createMockConfigKV(), requestLogService) + + await app.fetch( + new Request('http://localhost/api/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-4', messages: [] }), + }), + { user: testUser } as any, + ) + + expect(requestLogService.logRequest).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + model: 'gpt-4', + status: 200, + fluxConsumed: 1, + }), + ) + }) + }) + + describe('pOST /api/v1/audio/speech', () => { + it('should proxy TTS request to upstream', async () => { + const audioData = new Uint8Array([1, 2, 3, 4]) + globalThis.fetch = vi.fn(async () => new Response(audioData, { + status: 200, + headers: { 'Content-Type': 'audio/mpeg' }, + })) + + const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService()) + + const res = await app.fetch( + new Request('http://localhost/api/v1/audio/speech', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'tts-1', input: 'hello', voice: 'alloy' }), + }), + { user: testUser } as any, + ) + + expect(res.status).toBe(200) + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://mock-gateway/audio/speech', + expect.objectContaining({ method: 'POST' }), + ) + }) + }) + + describe('pOST /api/v1/audio/transcriptions', () => { + it('should proxy transcription request to upstream', async () => { + globalThis.fetch = vi.fn(async () => new Response('{"text":"hello"}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService()) + + const formData = new FormData() + formData.append('file', new Blob(['audio']), 'test.wav') + formData.append('model', 'whisper-1') + + const res = await app.fetch( + new Request('http://localhost/api/v1/audio/transcriptions', { + method: 'POST', + body: formData, + }), + { user: testUser } as any, + ) + + expect(res.status).toBe(200) + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://mock-gateway/audio/transcriptions', + expect.objectContaining({ method: 'POST' }), + ) + }) + }) + + describe('route matching', () => { + it('gET /api/v1/chat/completions should return 404', async () => { + const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService()) + + const res = await app.fetch( + new Request('http://localhost/api/v1/chat/completions', { method: 'GET' }), + { user: testUser } as any, + ) + expect(res.status).toBe(404) + }) + + it('pOST /api/v1/chat/completion (singular) should also work', async () => { + globalThis.fetch = vi.fn(async () => new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService()) + + const res = await app.fetch( + new Request('http://localhost/api/v1/chat/completion', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'auto', messages: [] }), + }), + { user: testUser } as any, + ) + expect(res.status).toBe(200) + }) + }) +}) diff --git a/apps/server/src/routes/v1completions.ts b/apps/server/src/routes/v1completions.ts index b7ee0e021..080d163bb 100644 --- a/apps/server/src/routes/v1completions.ts +++ b/apps/server/src/routes/v1completions.ts @@ -1,17 +1,23 @@ import type { Context } from 'hono' -import type { Env } from '../libs/env' +import type { initOtel } from '../libs/otel' import type { ConfigKVService } from '../services/config-kv' import type { FluxService } from '../services/flux' +import type { RequestLogService } from '../services/request-log' import type { HonoEnv } from '../types/hono' +import { useLogger } from '@guiiai/logg' +import { context, SpanStatusCode, trace } from '@opentelemetry/api' import { Hono } from 'hono' +import { bodyLimit } from 'hono/body-limit' import { authGuard } from '../middlewares/auth' import { configGuard } from '../middlewares/config-guard' import { createPaymentRequiredError } from '../utils/error' -// Only forward these headers from the upstream LLM response +type OtelMetrics = ReturnType +const tracer = trace.getTracer('v1-completions') + const SAFE_RESPONSE_HEADERS = new Set([ 'content-type', 'content-length', @@ -19,7 +25,59 @@ const SAFE_RESPONSE_HEADERS = new Set([ 'cache-control', ]) -export function createV1CompletionsRoutes(fluxService: FluxService, configKV: ConfigKVService, env: Env) { +function buildSafeResponseHeaders(response: Response): Headers { + const headers = new Headers() + for (const [key, value] of response.headers) { + if (SAFE_RESPONSE_HEADERS.has(key.toLowerCase())) + headers.set(key, value) + } + return headers +} + +function normalizeBaseUrl(gatewayBaseUrl: string): string { + return gatewayBaseUrl.endsWith('/') ? gatewayBaseUrl : `${gatewayBaseUrl}/` +} + +interface UsageInfo { + promptTokens?: number + completionTokens?: number +} + +function extractUsageFromBody(body: any): UsageInfo { + const usage = body?.usage + if (!usage) + return {} + return { + promptTokens: usage.prompt_tokens ?? undefined, + completionTokens: usage.completion_tokens ?? undefined, + } +} + +function calculateFluxFromUsage(usage: UsageInfo, fluxPer1kTokens: number, fallbackRate: number): number { + const { promptTokens, completionTokens } = usage + if (promptTokens != null && completionTokens != null) { + const totalTokens = promptTokens + completionTokens + return Math.max(1, Math.ceil(totalTokens / 1000 * fluxPer1kTokens)) + } + return fallbackRate +} + +export function createV1CompletionsRoutes(fluxService: FluxService, configKV: ConfigKVService, requestLogService: RequestLogService, otel: OtelMetrics | null) { + const logger = useLogger('v1-completions').useGlobalConfig() + + function recordMetrics(opts: { model: string, status: number, type: string, durationMs: number, fluxConsumed: number, promptTokens?: number, completionTokens?: number }) { + if (!otel) + return + const attrs = { model: opts.model, type: opts.type, status: opts.status } + otel.llmRequestCount.add(1, attrs) + otel.llmRequestDuration.record(opts.durationMs, attrs) + otel.fluxConsumed.add(opts.fluxConsumed, { model: opts.model, type: opts.type }) + if (opts.promptTokens != null) + otel.llmTokensPrompt.add(opts.promptTokens, { model: opts.model }) + if (opts.completionTokens != null) + otel.llmTokensCompletion.add(opts.completionTokens, { model: opts.model }) + } + async function handleCompletion(c: Context) { const user = c.get('user')! const flux = await fluxService.getFlux(user.id) @@ -28,33 +86,276 @@ export function createV1CompletionsRoutes(fluxService: FluxService, configKV: Co } const body = await c.req.json() + const gatewayBaseUrl = await configKV.getOrThrow('GATEWAY_BASE_URL') + const baseUrl = normalizeBaseUrl(gatewayBaseUrl) + let requestModel = body.model || 'auto' - const fluxPerRequest = await configKV.getOrThrow('FLUX_PER_REQUEST') - await fluxService.consumeFlux(user.id, fluxPerRequest) + if (requestModel === 'auto') { + requestModel = await configKV.getOrThrow('DEFAULT_CHAT_MODEL') + } - const response = await fetch(`${env.BACKEND_LLM_BASE_URL}chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${env.BACKEND_LLM_API_KEY}`, + const span = tracer.startSpan('llm.gateway.chat', { + attributes: { + 'llm.model': requestModel, + 'llm.stream': !!body.stream, }, - body: JSON.stringify(body), }) - const headers = new Headers() - for (const [key, value] of response.headers) { - if (SAFE_RESPONSE_HEADERS.has(key.toLowerCase())) - headers.set(key, value) + const startedAt = Date.now() + + const response = await context.with(trace.setSpan(context.active(), span), () => + fetch(`${baseUrl}chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...body, model: requestModel }), + })) + + const durationMs = Date.now() - startedAt + span.setAttribute('http.response.status_code', response.status) + + if (!response.ok) { + span.setStatus({ code: SpanStatusCode.ERROR, message: `Gateway ${response.status}` }) + span.end() + recordMetrics({ model: requestModel, status: response.status, type: 'chat', durationMs, fluxConsumed: 0 }) + return new Response(response.body, { + status: response.status, + headers: buildSafeResponseHeaders(response), + }) } + // Post-billing: parse usage and charge after successful response + const fallbackRate = await configKV.getOrThrow('FLUX_PER_REQUEST') + const fluxPer1kTokens = (await configKV.getOptional('FLUX_PER_1K_TOKENS')) ?? 1 + + if (body.stream) { + // Streaming: return response immediately, bill after stream ends + const { readable, writable } = new TransformStream() + const reader = response.body!.getReader() + const writer = writable.getWriter() + // Buffer last 2KB to handle chunk boundary splits for usage extraction + let tailBuffer = '' + + // Process stream in background + ;(async () => { + try { + while (true) { + const { done, value } = await reader.read() + if (done) + break + await writer.write(value) + const text = new TextDecoder().decode(value) + tailBuffer = (tailBuffer + text).slice(-2048) + } + } + finally { + await writer.close() + + // Extract usage from final SSE data lines + let usage: UsageInfo = {} + try { + const lines = tailBuffer.split('\n').filter(l => l.startsWith('data: ') && !l.includes('[DONE]')) + const lastDataLine = lines[lines.length - 1] + if (lastDataLine) { + const json = JSON.parse(lastDataLine.slice(6)) + usage = extractUsageFromBody(json) + } + } + catch (err) { logger.withError(err).warn('Failed to extract usage from stream, falling back to flat rate') } + + const fluxConsumed = calculateFluxFromUsage(usage, fluxPer1kTokens, fallbackRate) + + span.setAttributes({ + 'llm.tokens.prompt': usage.promptTokens ?? 0, + 'llm.tokens.completion': usage.completionTokens ?? 0, + 'llm.flux_consumed': fluxConsumed, + }) + span.end() + recordMetrics({ model: requestModel, status: response.status, type: 'chat', durationMs, fluxConsumed, ...usage }) + + // Best-effort billing — don't throw on insufficient flux during streaming + try { + await fluxService.consumeFlux(user.id, fluxConsumed) + } + catch (err) { logger.withError(err).withFields({ userId: user.id, fluxConsumed }).warn('Failed to consume flux after streaming') } + + requestLogService.logRequest({ + userId: user.id, + model: requestModel, + status: response.status, + durationMs, + fluxConsumed, + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + }).catch(err => logger.withError(err).warn('Failed to log streaming request')) + } + })() + + return new Response(readable, { + status: response.status, + headers: buildSafeResponseHeaders(response), + }) + } + + // Non-streaming: parse response, bill, then return + const responseBody = await response.json() + const usage = extractUsageFromBody(responseBody) + const fluxConsumed = calculateFluxFromUsage(usage, fluxPer1kTokens, fallbackRate) + + span.setAttributes({ + 'llm.tokens.prompt': usage.promptTokens ?? 0, + 'llm.tokens.completion': usage.completionTokens ?? 0, + 'llm.flux_consumed': fluxConsumed, + }) + span.end() + recordMetrics({ model: requestModel, status: response.status, type: 'chat', durationMs, fluxConsumed, ...usage }) + + // Best-effort billing — gateway already processed the request, + // don't return 402 after work is done + try { + await fluxService.consumeFlux(user.id, fluxConsumed) + } + catch (err) { logger.withError(err).withFields({ userId: user.id, fluxConsumed }).warn('Failed to consume flux') } + + requestLogService.logRequest({ + userId: user.id, + model: requestModel, + status: response.status, + durationMs, + fluxConsumed, + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + }).catch(err => logger.withError(err).warn('Failed to log request')) + + return c.json(responseBody) + } + + async function handleTTS(c: Context) { + const user = c.get('user')! + const flux = await fluxService.getFlux(user.id) + if (flux.flux <= 0) { + throw createPaymentRequiredError('Insufficient flux') + } + + const body = await c.req.json() + const gatewayBaseUrl = await configKV.getOrThrow('GATEWAY_BASE_URL') + const baseUrl = normalizeBaseUrl(gatewayBaseUrl) + const requestModel = body.model || 'auto' + + const span = tracer.startSpan('llm.gateway.tts', { + attributes: { 'llm.model': requestModel }, + }) + + const startedAt = Date.now() + + const response = await context.with(trace.setSpan(context.active(), span), () => + fetch(`${baseUrl}audio/speech`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + })) + + const durationMs = Date.now() - startedAt + span.setAttribute('http.response.status_code', response.status) + + if (!response.ok) { + span.setStatus({ code: SpanStatusCode.ERROR, message: `Gateway ${response.status}` }) + span.end() + recordMetrics({ model: requestModel, status: response.status, type: 'tts', durationMs, fluxConsumed: 0 }) + return new Response(response.body, { + status: response.status, + headers: buildSafeResponseHeaders(response), + }) + } + + const fluxPerRequest = await configKV.getOrThrow('FLUX_PER_REQUEST_TTS') + await fluxService.consumeFlux(user.id, fluxPerRequest) + + span.setAttribute('llm.flux_consumed', fluxPerRequest) + span.end() + recordMetrics({ model: requestModel, status: response.status, type: 'tts', durationMs, fluxConsumed: fluxPerRequest }) + + requestLogService.logRequest({ + userId: user.id, + model: requestModel, + status: response.status, + durationMs, + fluxConsumed: fluxPerRequest, + }).catch(err => logger.withError(err).warn('Failed to log TTS request')) + return new Response(response.body, { status: response.status, - headers, + headers: buildSafeResponseHeaders(response), }) } + async function handleTranscription(c: Context) { + const user = c.get('user')! + const flux = await fluxService.getFlux(user.id) + if (flux.flux <= 0) { + throw createPaymentRequiredError('Insufficient flux') + } + + const gatewayBaseUrl = await configKV.getOrThrow('GATEWAY_BASE_URL') + const baseUrl = normalizeBaseUrl(gatewayBaseUrl) + + const span = tracer.startSpan('llm.gateway.asr', { + attributes: { 'llm.model': 'auto' }, + }) + + const startedAt = Date.now() + + const rawBody = await c.req.arrayBuffer() + const contentType = c.req.header('content-type') || 'multipart/form-data' + + const response = await context.with(trace.setSpan(context.active(), span), () => + fetch(`${baseUrl}audio/transcriptions`, { + method: 'POST', + headers: { 'Content-Type': contentType }, + body: rawBody, + })) + + const durationMs = Date.now() - startedAt + span.setAttribute('http.response.status_code', response.status) + + if (!response.ok) { + span.setStatus({ code: SpanStatusCode.ERROR, message: `Gateway ${response.status}` }) + span.end() + recordMetrics({ model: 'auto', status: response.status, type: 'asr', durationMs, fluxConsumed: 0 }) + return new Response(response.body, { + status: response.status, + headers: buildSafeResponseHeaders(response), + }) + } + + const fluxPerRequest = await configKV.getOrThrow('FLUX_PER_REQUEST_ASR') + await fluxService.consumeFlux(user.id, fluxPerRequest) + + span.setAttribute('llm.flux_consumed', fluxPerRequest) + span.end() + recordMetrics({ model: 'auto', status: response.status, type: 'asr', durationMs, fluxConsumed: fluxPerRequest }) + + requestLogService.logRequest({ + userId: user.id, + model: 'auto', + status: response.status, + durationMs, + fluxConsumed: fluxPerRequest, + }).catch(err => logger.withError(err).warn('Failed to log ASR request')) + + return new Response(response.body, { + status: response.status, + headers: buildSafeResponseHeaders(response), + }) + } + + const chatGuard = configGuard(configKV, ['FLUX_PER_REQUEST', 'GATEWAY_BASE_URL', 'DEFAULT_CHAT_MODEL'], 'Service is not available yet') + const ttsGuard = configGuard(configKV, ['FLUX_PER_REQUEST_TTS', 'GATEWAY_BASE_URL'], 'TTS service is not available yet') + const asrGuard = configGuard(configKV, ['FLUX_PER_REQUEST_ASR', 'GATEWAY_BASE_URL'], 'ASR service is not available yet') + return new Hono() - .use('*', authGuard, configGuard(configKV, ['FLUX_PER_REQUEST'], 'Service is not available yet')) - .post('/chat/completions', handleCompletion) - .post('/chat/completion', handleCompletion) + .use('*', authGuard) + .post('/chat/completions', chatGuard, handleCompletion) + .post('/chat/completion', chatGuard, handleCompletion) + .post('/audio/speech', ttsGuard, handleTTS) + .post('/audio/transcriptions', bodyLimit({ maxSize: 25 * 1024 * 1024 }), asrGuard, handleTranscription) } diff --git a/apps/server/src/schemas/index.ts b/apps/server/src/schemas/index.ts index 9990002e5..8cafa00de 100644 --- a/apps/server/src/schemas/index.ts +++ b/apps/server/src/schemas/index.ts @@ -2,6 +2,7 @@ export * from './accounts' export * from './characters' export * from './chats' export * from './flux' +export * from './llm-request-log' export * from './providers' export * from './stripe' export * from './user-character' diff --git a/apps/server/src/schemas/llm-request-log.ts b/apps/server/src/schemas/llm-request-log.ts new file mode 100644 index 000000000..c102a592f --- /dev/null +++ b/apps/server/src/schemas/llm-request-log.ts @@ -0,0 +1,17 @@ +import { boolean, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' + +import { nanoid } from '../utils/id' +import { user } from './accounts' + +export const llmRequestLog = pgTable('llm_request_log', { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + model: text('model').notNull(), + status: integer('status').notNull(), + durationMs: integer('duration_ms').notNull(), + fluxConsumed: integer('flux_consumed').notNull(), + promptTokens: integer('prompt_tokens'), + completionTokens: integer('completion_tokens'), + settled: boolean('settled').notNull().default(false), + createdAt: timestamp('created_at').defaultNow().notNull(), +}) diff --git a/apps/server/src/services/__test__/flux-write-back.test.ts b/apps/server/src/services/__test__/flux-write-back.test.ts new file mode 100644 index 000000000..c501f1b06 --- /dev/null +++ b/apps/server/src/services/__test__/flux-write-back.test.ts @@ -0,0 +1,105 @@ +import { eq } from 'drizzle-orm' +import { beforeAll, describe, expect, it } from 'vitest' + +import { mockDB } from '../../libs/mock-db' +import { createFluxWriteBack } from '../flux-write-back' + +import * as schema from '../../schemas' + +describe('fluxWriteBack', () => { + let db: any + let testUser: any + let writeBack: ReturnType + + beforeAll(async () => { + db = await mockDB(schema) + + const [user] = await db.insert(schema.user).values({ + id: 'user-wb-1', + name: 'Write-back User', + email: 'wb@example.com', + }).returning() + testUser = user + + await db.insert(schema.userFlux).values({ + userId: testUser.id, + flux: 1000, + }) + + writeBack = createFluxWriteBack(db) + }) + + it('should aggregate unsettled logs and deduct from user_flux', async () => { + await db.insert(schema.llmRequestLog).values([ + { userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 10, settled: false }, + { userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 200, fluxConsumed: 20, settled: false }, + { userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 150, fluxConsumed: 30, settled: false }, + ]) + + await writeBack.flush() + + const record = await db.query.userFlux.findFirst({ + where: eq(schema.userFlux.userId, testUser.id), + }) + expect(record.flux).toBe(940) + + const unsettled = await db.query.llmRequestLog.findMany({ + where: eq(schema.llmRequestLog.settled, false), + }) + expect(unsettled).toHaveLength(0) + }) + + it('should not re-settle already settled logs', async () => { + await db.insert(schema.llmRequestLog).values({ + userId: testUser.id, + model: 'gpt-4', + status: 200, + durationMs: 100, + fluxConsumed: 5, + settled: false, + }) + + await writeBack.flush() + + const record = await db.query.userFlux.findFirst({ + where: eq(schema.userFlux.userId, testUser.id), + }) + expect(record.flux).toBe(935) + }) + + it('should be a no-op when there are no unsettled logs', async () => { + await writeBack.flush() + + const record = await db.query.userFlux.findFirst({ + where: eq(schema.userFlux.userId, testUser.id), + }) + expect(record.flux).toBe(935) + }) + + it('should aggregate across multiple users correctly', async () => { + const [user2] = await db.insert(schema.user).values({ + id: 'user-wb-2', + name: 'Write-back User 2', + email: 'wb2@example.com', + }).returning() + await db.insert(schema.userFlux).values({ userId: user2.id, flux: 500 }) + + await db.insert(schema.llmRequestLog).values([ + { userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 15, settled: false }, + { userId: user2.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 25, settled: false }, + { userId: user2.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 35, settled: false }, + ]) + + await writeBack.flush() + + const record1 = await db.query.userFlux.findFirst({ + where: eq(schema.userFlux.userId, testUser.id), + }) + expect(record1.flux).toBe(920) + + const record2 = await db.query.userFlux.findFirst({ + where: eq(schema.userFlux.userId, user2.id), + }) + expect(record2.flux).toBe(440) + }) +}) diff --git a/apps/server/src/services/__test__/flux.test.ts b/apps/server/src/services/__test__/flux.test.ts index 7e62ec521..a0533abf1 100644 --- a/apps/server/src/services/__test__/flux.test.ts +++ b/apps/server/src/services/__test__/flux.test.ts @@ -1,6 +1,8 @@ +import type Redis from 'ioredis' + import type { createConfigKVService } from '../config-kv' -import { beforeAll, describe, expect, it, vi } from 'vitest' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDB } from '../../libs/mock-db' import { createFluxService } from '../flux' @@ -11,21 +13,41 @@ function createMockConfigKV(overrides: Record = {}): ReturnType< const defaults: Record = { INITIAL_USER_FLUX: 100, FLUX_PER_CENT: 1, FLUX_PER_REQUEST: 1, ...overrides } return { get: vi.fn(async (key: string) => defaults[key]), + getOrThrow: vi.fn(async (key: string) => defaults[key]), getOptional: vi.fn(async (key: string) => defaults[key] ?? null), set: vi.fn(), } as any } -describe('fluxService', () => { +function createMockRedis(): Redis { + const store = new Map() + return { + get: vi.fn(async (key: string) => store.get(key) ?? null), + set: vi.fn(async (key: string, value: string) => { store.set(key, value); return 'OK' }), + decrby: vi.fn(async (key: string, amount: number) => { + const current = Number.parseInt(store.get(key) ?? '0', 10) + const next = current - amount + store.set(key, String(next)) + return next + }), + incrby: vi.fn(async (key: string, amount: number) => { + const current = Number.parseInt(store.get(key) ?? '0', 10) + const next = current + amount + store.set(key, String(next)) + return next + }), + } as unknown as Redis +} + +describe('fluxService (Redis-backed)', () => { let db: any + let redis: Redis let service: ReturnType let testUser: any beforeAll(async () => { db = await mockDB(schema) - service = createFluxService(db, createMockConfigKV()) - // Create a test user for foreign key constraints const [user] = await db.insert(schema.user).values({ id: 'user-1', name: 'Test User', @@ -34,133 +56,104 @@ describe('fluxService', () => { testUser = user }) - // --- getFlux --- + beforeEach(() => { + redis = createMockRedis() + service = createFluxService(db, redis, createMockConfigKV()) + }) - it('getFlux should create a new record with 100 default flux for a new user', async () => { + it('getFlux should load from DB on cache miss and populate Redis', async () => { const record = await service.getFlux(testUser.id) - - expect(record).toBeDefined() - expect(record.userId).toBe(testUser.id) expect(record.flux).toBe(100) + expect(redis.set).toHaveBeenCalledWith(`flux:${testUser.id}`, '100') }) - it('getFlux should return existing record on subsequent calls', async () => { - const first = await service.getFlux(testUser.id) - const second = await service.getFlux(testUser.id) - - // Same record, no duplicate insert - expect(second.userId).toBe(first.userId) - expect(second.flux).toBe(first.flux) + it('getFlux should return cached value on subsequent calls', async () => { + await service.getFlux(testUser.id) + await service.getFlux(testUser.id) + expect(redis.get).toHaveBeenCalledTimes(2) }) - // --- consumeFlux --- - - it('consumeFlux should deduct flux correctly', async () => { + it('consumeFlux should deduct via Redis DECRBY', async () => { + await service.getFlux(testUser.id) const result = await service.consumeFlux(testUser.id, 10) + expect(result.flux).toBe(90) + expect(redis.decrby).toHaveBeenCalledWith(`flux:${testUser.id}`, 10) + }) - // Started at 100, consumed 10 + it('consumeFlux should throw and rollback when insufficient', async () => { + await service.getFlux(testUser.id) + await expect(service.consumeFlux(testUser.id, 101)) + .rejects + .toThrow('Insufficient flux') + expect(redis.incrby).toHaveBeenCalledWith(`flux:${testUser.id}`, 101) + }) + + it('addFlux should update both DB and Redis', async () => { + await service.getFlux(testUser.id) + const result = await service.addFlux(testUser.id, 50) + expect(result.flux).toBe(150) + expect(redis.incrby).toHaveBeenCalledWith(`flux:${testUser.id}`, 50) + }) + + it('consumeFlux should lazy-load cache if not preloaded', async () => { + const [user2] = await db.insert(schema.user).values({ + id: 'user-lazy', + name: 'Lazy User', + email: 'lazy@example.com', + }).returning() + const result = await service.consumeFlux(user2.id, 10) expect(result.flux).toBe(90) }) - it('consumeFlux should throw when balance is insufficient', async () => { - // Current balance is 90 after previous test; consuming 91 should fail - await expect(service.consumeFlux(testUser.id, 91)) - .rejects - .toThrow('Insufficient flux') + it('getFlux should return updated value after consumeFlux', async () => { + const [user] = await db.insert(schema.user).values({ + id: 'user-consume-then-get', + name: 'Consume Then Get', + email: 'consume-then-get@example.com', + }).returning() + await service.getFlux(user.id) + await service.consumeFlux(user.id, 25) + const record = await service.getFlux(user.id) + expect(record.flux).toBe(75) }) - it('consumeFlux should throw when trying to consume more than available', async () => { - await expect(service.consumeFlux(testUser.id, 999)) - .rejects - .toThrow('Insufficient flux') - }) - - // --- addFlux --- - - it('addFlux should add flux correctly', async () => { - // Balance is 90 from previous consume test - const result = await service.addFlux(testUser.id, 50) - expect(result.flux).toBe(140) - }) - - it('addFlux should accumulate across multiple calls', async () => { - // Balance is 140; add 10 three times - await service.addFlux(testUser.id, 10) - await service.addFlux(testUser.id, 10) - const result = await service.addFlux(testUser.id, 10) - - expect(result.flux).toBe(170) - }) - - // --- updateStripeCustomerId --- - - it('updateStripeCustomerId should update the stripe customer ID', async () => { + it('updateStripeCustomerId should update DB only', async () => { + await service.getFlux(testUser.id) const result = await service.updateStripeCustomerId(testUser.id, 'cus_abc123') - - expect(result.stripeCustomerId).toBe('cus_abc123') - - // Verify it persists via getFlux - const record = await service.getFlux(testUser.id) - expect(record.stripeCustomerId).toBe('cus_abc123') + expect(result!.stripeCustomerId).toBe('cus_abc123') }) - // --- Concurrent consumeFlux --- - it('concurrent consumeFlux should not over-deduct flux', async () => { - // Set up a fresh user to isolate this test from previous state - const [user2] = await db.insert(schema.user).values({ + const [user3] = await db.insert(schema.user).values({ id: 'user-concurrent-consume', name: 'Concurrent Consumer', email: 'concurrent-consume@example.com', }).returning() - - // Initialize flux record (100 default) - await service.getFlux(user2.id) - - // Fire 10 concurrent consume calls of 10 each (total 100, exactly the balance) + await service.getFlux(user3.id) const results = await Promise.allSettled( - Array.from({ length: 10 }, () => service.consumeFlux(user2.id, 10)), + Array.from({ length: 10 }, () => service.consumeFlux(user3.id, 10)), ) - const fulfilled = results.filter(r => r.status === 'fulfilled') const rejected = results.filter(r => r.status === 'rejected') - - // All 10 should succeed since total equals balance, but under concurrency - // some may fail if the atomic check-and-deduct fires after balance drops. - // The key invariant: final balance must never go negative. - const finalRecord = await service.getFlux(user2.id) - expect(finalRecord.flux).toBeGreaterThanOrEqual(0) - - // Total consumed must equal (fulfilled count * 10) - expect(finalRecord.flux).toBe(100 - fulfilled.length * 10) - - // Every rejection should be 'Insufficient flux' + const final = await service.getFlux(user3.id) + expect(final.flux).toBeGreaterThanOrEqual(0) + expect(final.flux).toBe(100 - fulfilled.length * 10) for (const r of rejected) { expect((r as PromiseRejectedResult).reason.message).toBe('Insufficient flux') } }) - // --- Concurrent addFlux --- - - it('concurrent addFlux should accumulate correctly without lost updates', async () => { - // Set up a fresh user to isolate this test - const [user3] = await db.insert(schema.user).values({ + it('concurrent addFlux should accumulate correctly', async () => { + const [user4] = await db.insert(schema.user).values({ id: 'user-concurrent-add', name: 'Concurrent Adder', email: 'concurrent-add@example.com', }).returning() - - // Initialize flux record (100 default) - await service.getFlux(user3.id) - - // Fire 10 concurrent add calls of 5 each (expect +50 total) + await service.getFlux(user4.id) await Promise.all( - Array.from({ length: 10 }, () => service.addFlux(user3.id, 5)), + Array.from({ length: 10 }, () => service.addFlux(user4.id, 5)), ) - - const finalRecord = await service.getFlux(user3.id) - - // 100 initial + 10 * 5 = 150 - expect(finalRecord.flux).toBe(150) + const final = await service.getFlux(user4.id) + expect(final.flux).toBe(150) }) }) diff --git a/apps/server/src/services/config-kv.ts b/apps/server/src/services/config-kv.ts index 924ca5f4e..a7b73d5a2 100644 --- a/apps/server/src/services/config-kv.ts +++ b/apps/server/src/services/config-kv.ts @@ -14,16 +14,25 @@ export interface FluxPackage { interface ConfigDefinitions { FLUX_PER_CENT: number FLUX_PER_REQUEST: number + FLUX_PER_REQUEST_TTS: number + FLUX_PER_REQUEST_ASR: number INITIAL_USER_FLUX: number FLUX_PACKAGES: FluxPackage[] + FLUX_PER_1K_TOKENS: number + GATEWAY_BASE_URL: string + DEFAULT_CHAT_MODEL: string } +const NUMERIC_KEYS = new Set(['FLUX_PER_CENT', 'FLUX_PER_REQUEST', 'FLUX_PER_REQUEST_TTS', 'FLUX_PER_REQUEST_ASR', 'INITIAL_USER_FLUX', 'FLUX_PER_1K_TOKENS']) + const KEY_PREFIX = 'config:' function parseValue(key: K, raw: string): ConfigDefinitions[K] { if (key === 'FLUX_PACKAGES') return JSON.parse(raw) as ConfigDefinitions[K] - return Number(raw) as ConfigDefinitions[K] + if (NUMERIC_KEYS.has(key)) + return Number(raw) as ConfigDefinitions[K] + return raw as ConfigDefinitions[K] } function serializeValue(key: K, value: ConfigDefinitions[K]): string { @@ -50,6 +59,14 @@ export function createConfigKVService(redis: Redis) { return value }, + async get(key: K): Promise { + const value = await this.getOptional(key) + if (value === null) + throw createServiceUnavailableError(`Config key "${key}" is not set in Redis`, 'CONFIG_NOT_SET') + + return value + }, + async set(key: K, value: ConfigDefinitions[K]): Promise { await redis.set(`${KEY_PREFIX}${key}`, serializeValue(key, value)) }, diff --git a/apps/server/src/services/flux-write-back.ts b/apps/server/src/services/flux-write-back.ts new file mode 100644 index 000000000..de1c799e3 --- /dev/null +++ b/apps/server/src/services/flux-write-back.ts @@ -0,0 +1,74 @@ +import type { Database } from '../libs/db' + +import { useLogger } from '@guiiai/logg' +import { and, eq, lte, sql } from 'drizzle-orm' + +import * as fluxSchema from '../schemas/flux' +import * as logSchema from '../schemas/llm-request-log' + +/** + * NOTE: Flux balances are deducted in real-time via Redis (DECRBY) in FluxService.consumeFlux(). + * This write-back service only syncs the DB — it does NOT touch Redis. + * It periodically aggregates unsettled request logs and batch-updates the DB's user_flux table + * so that the persistent balance stays consistent with the Redis cache. + */ +export function createFluxWriteBack(db: Database) { + const logger = useLogger('flux-write-back').useGlobalConfig() + let timer: ReturnType | null = null + + async function flush() { + const snapshotTime = new Date() + + // 1. Aggregate unsettled logs inserted before (or at) this tick + const totals = await db + .select({ + userId: logSchema.llmRequestLog.userId, + total: sql`SUM(${logSchema.llmRequestLog.fluxConsumed})`.as('total'), + }) + .from(logSchema.llmRequestLog) + .where(and(eq(logSchema.llmRequestLog.settled, false), lte(logSchema.llmRequestLog.createdAt, snapshotTime))) + .groupBy(logSchema.llmRequestLog.userId) + + if (totals.length === 0) + return + + // 2. Batch update in transaction + await db.transaction(async (tx) => { + for (const { userId, total } of totals) { + await tx.update(fluxSchema.userFlux) + .set({ + flux: sql`${fluxSchema.userFlux.flux} - ${total}`, + updatedAt: new Date(), + }) + .where(eq(fluxSchema.userFlux.userId, userId)) + } + + await tx.update(logSchema.llmRequestLog) + .set({ settled: true }) + .where(and(eq(logSchema.llmRequestLog.settled, false), lte(logSchema.llmRequestLog.createdAt, snapshotTime))) + }) + + logger.withFields({ userCount: totals.length }).log('Write-back completed') + } + + return { + flush, + + start(intervalMs = 60_000) { + timer = setInterval(() => { + flush().catch((err) => { + logger.withError(err).error('Write-back failed') + }) + }, intervalMs) + }, + + stop() { + if (timer) { + clearInterval(timer) + timer = null + } + }, + } +} + +export type FluxWriteBack = ReturnType diff --git a/apps/server/src/services/flux.ts b/apps/server/src/services/flux.ts index 8f10f0dbd..4b25ec00e 100644 --- a/apps/server/src/services/flux.ts +++ b/apps/server/src/services/flux.ts @@ -1,15 +1,28 @@ +import type Redis from 'ioredis' + import type { Database } from '../libs/db' import type { ConfigKVService } from './config-kv' -import { and, eq, gte, sql } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { createPaymentRequiredError } from '../utils/error' import * as schema from '../schemas/flux' -export function createFluxService(db: Database, configKV: ConfigKVService) { +function redisKey(userId: string): string { + return `flux:${userId}` +} + +export function createFluxService(db: Database, redis: Redis, configKV: ConfigKVService) { return { async getFlux(userId: string) { + // 1. Try Redis cache + const cached = await redis.get(redisKey(userId)) + if (cached !== null) { + return { userId, flux: Number.parseInt(cached, 10) } + } + + // 2. Cache miss — load from DB let record = await db.query.userFlux.findFirst({ where: eq(schema.userFlux.userId, userId), }) @@ -22,46 +35,48 @@ export function createFluxService(db: Database, configKV: ConfigKVService) { }).returning() } + // 3. Populate Redis cache + await redis.set(redisKey(userId), String(record.flux)) + return record }, async consumeFlux(userId: string, amount: number) { - // Ensure the user has a flux record + // Ensure Redis key exists before DECRBY + // (DECRBY on a nonexistent key creates it at 0, giving wrong balance) await this.getFlux(userId) - // Atomic check-and-deduct to prevent race conditions - const result = await db.update(schema.userFlux) - .set({ - flux: sql`${schema.userFlux.flux} - ${amount}`, - updatedAt: new Date(), - }) - .where(and( - eq(schema.userFlux.userId, userId), - gte(schema.userFlux.flux, amount), - )) - .returning() - - if (result.length === 0) { + // Atomic decrement — check result. + // Note: there is a small race window between DECRBY returning negative + // and INCRBY rolling back, during which another concurrent request could + // see the negative balance and also attempt rollback. We accept this + // trade-off — the initial balance check is the real guard, and this + // DECRBY+rollback is a safety net, not a guarantee. + const newBalance = await redis.decrby(redisKey(userId), amount) + if (newBalance < 0) { + await redis.incrby(redisKey(userId), amount) throw createPaymentRequiredError('Insufficient flux') } - return result[0] + return { userId, flux: newBalance } }, async addFlux(userId: string, amount: number) { - // Ensure the user has a flux record + // Ensure user record exists in DB await this.getFlux(userId) - // Atomic addition to prevent race conditions - const [updated] = await db.update(schema.userFlux) + // DB update (persistence for Stripe payments) + await db.update(schema.userFlux) .set({ flux: sql`${schema.userFlux.flux} + ${amount}`, updatedAt: new Date(), }) .where(eq(schema.userFlux.userId, userId)) - .returning() - return updated + // Sync Redis cache + const newBalance = await redis.incrby(redisKey(userId), amount) + + return { userId, flux: newBalance } }, async updateStripeCustomerId(userId: string, stripeCustomerId: string) { diff --git a/apps/server/src/services/request-log.ts b/apps/server/src/services/request-log.ts new file mode 100644 index 000000000..36fccc6e4 --- /dev/null +++ b/apps/server/src/services/request-log.ts @@ -0,0 +1,23 @@ +import type { Database } from '../libs/db' + +import * as schema from '../schemas/llm-request-log' + +export interface RequestLogEntry { + userId: string + model: string + status: number + durationMs: number + fluxConsumed: number + promptTokens?: number + completionTokens?: number +} + +export function createRequestLogService(db: Database) { + return { + async logRequest(entry: RequestLogEntry) { + await db.insert(schema.llmRequestLog).values(entry) + }, + } +} + +export type RequestLogService = ReturnType diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 142144b83..fd480a6bf 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -874,6 +874,10 @@ pages: official: title: Official Provider description: Official AI provider by AIRI. + speech-title: Official Speech Provider + speech-description: Official text-to-speech provider by AIRI. + transcription-title: Official Transcription Provider + transcription-description: Official speech-to-text provider by AIRI. transcriptions: playground: title: Transcription Playground diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 95863d4d5..62b2b0e78 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -835,6 +835,13 @@ pages: aliyun-nls: description: Aliyun 智能语音服务 title: Aliyun 智能语音服务 + official: + title: 官方服务 + description: 由 AIRI 提供的官方 AI 服务。 + speech-title: 官方语音合成服务 + speech-description: 由 AIRI 提供的官方文字转语音服务。 + transcription-title: 官方语音识别服务 + transcription-description: 由 AIRI 提供的官方语音转文字服务。 browser-web-speech-api: description: 浏览器原生STT (需要 Chrome/Edge/Safari) title: Web 语音API diff --git a/packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech.vue b/packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech.vue new file mode 100644 index 000000000..cd03524d1 --- /dev/null +++ b/packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech.vue @@ -0,0 +1,98 @@ + + + + + +meta: + layout: settings + stageTransition: + name: slide + diff --git a/packages/stage-pages/src/pages/settings/providers/transcription/official-provider-transcription.vue b/packages/stage-pages/src/pages/settings/providers/transcription/official-provider-transcription.vue new file mode 100644 index 000000000..3c759a876 --- /dev/null +++ b/packages/stage-pages/src/pages/settings/providers/transcription/official-provider-transcription.vue @@ -0,0 +1,98 @@ + + + + + +meta: + layout: settings + stageTransition: + name: slide + diff --git a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue index cbbce2e95..153cf4fdc 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue @@ -8,12 +8,14 @@ import { useI18n } from 'vue-i18n' import onboardingLogo from '../../../../assets/onboarding.avif' import { useAuthStore } from '../../../../stores/auth' +import { useOnboardingStore } from '../../../../stores/onboarding' import { useSettingsGeneral } from '../../../../stores/settings' import { OnboardingContextKey } from './utils' const { t } = useI18n() const context = inject(OnboardingContextKey)! const authStore = useAuthStore() +const onboardingStore = useOnboardingStore() const settingsStore = useSettingsGeneral() const { language } = storeToRefs(settingsStore) @@ -22,6 +24,7 @@ const languages = computed(() => { }) function handleLogin() { + onboardingStore.shouldShowSetup = false authStore.isLoginOpen = true } diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index bf70f25de..d6b53c812 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -25,6 +25,7 @@ import { storeToRefs } from 'pinia' import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { useDelayMessageQueue, useEmotionsMessageQueue } from '../../composables/queues' +import { useAuthProviderSync } from '../../composables/use-auth-provider-sync' import { llmInferenceEndToken } from '../../constants' import { EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions' import { useAudioContext, useSpeakingStore } from '../../stores/audio' @@ -78,6 +79,7 @@ const chatHookCleanups: Array<() => void> = [] // cross-window broadcast wiring. const providersStore = useProvidersStore() +useAuthProviderSync() const live2dStore = useLive2d() const showStage = ref(true) const viewUpdateCleanups: Array<() => void> = [] diff --git a/packages/stage-ui/src/composables/use-auth-provider-sync.ts b/packages/stage-ui/src/composables/use-auth-provider-sync.ts new file mode 100644 index 000000000..139ecc4a4 --- /dev/null +++ b/packages/stage-ui/src/composables/use-auth-provider-sync.ts @@ -0,0 +1,59 @@ +import { nextTick, watch } from 'vue' + +import { initializeAuth } from '../libs/auth' +import { useAuthStore } from '../stores/auth' +import { useConsciousnessStore } from '../stores/modules/consciousness' +import { useHearingStore } from '../stores/modules/hearing' +import { useSpeechStore } from '../stores/modules/speech' +import { useProvidersStore } from '../stores/providers' + +/** + * Coordinates auth state with provider/module stores. + * + * When the user becomes authenticated, this composable automatically enables + * the official providers and sets them as active across consciousness, speech, + * and hearing modules. + * + * Call once at the app root (e.g. Stage.vue). + */ +export function useAuthProviderSync() { + initializeAuth() + + const authState = useAuthStore() + const providersStore = useProvidersStore() + const consciousnessStore = useConsciousnessStore() + const speechStore = useSpeechStore() + const hearingStore = useHearingStore() + + watch(() => authState.isAuthenticated, async (val) => { + if (!val) + return + + const officialProviderId = 'official-provider' + const officialSpeechId = 'official-provider-speech' + const officialTranscriptionId = 'official-provider-transcription' + + providersStore.forceProviderConfigured(officialProviderId) + providersStore.forceProviderConfigured(officialSpeechId) + providersStore.forceProviderConfigured(officialTranscriptionId) + + consciousnessStore.activeProvider = officialProviderId + consciousnessStore.activeModel = 'auto' + speechStore.activeSpeechProvider = officialSpeechId + speechStore.activeSpeechModel = 'auto' + hearingStore.activeTranscriptionProvider = officialTranscriptionId + hearingStore.activeTranscriptionModel = 'auto' + + await nextTick() + try { + await Promise.all([ + consciousnessStore.loadModelsForProvider(officialProviderId), + providersStore.fetchModelsForProvider(officialSpeechId), + providersStore.fetchModelsForProvider(officialTranscriptionId), + ]) + } + catch (err) { + console.error('error loading models for official providers', err) + } + }, { immediate: true }) +} diff --git a/packages/stage-ui/src/libs/auth.ts b/packages/stage-ui/src/libs/auth.ts index d2dd71329..dcfdf24fc 100644 --- a/packages/stage-ui/src/libs/auth.ts +++ b/packages/stage-ui/src/libs/auth.ts @@ -10,11 +10,20 @@ export const authClient = createAuthClient({ credentials: 'include', }) +let initialized = false + +export function initializeAuth() { + if (initialized) + return + + fetchSession().catch(() => {}) + initialized = true +} + export async function fetchSession() { const { data } = await authClient.getSession() if (data) { const authStore = useAuthStore() - authStore.user = data.user authStore.session = data.session return true diff --git a/packages/stage-ui/src/stores/auth.ts b/packages/stage-ui/src/stores/auth.ts index 57bec45fd..3847a20fd 100644 --- a/packages/stage-ui/src/stores/auth.ts +++ b/packages/stage-ui/src/stores/auth.ts @@ -1,13 +1,16 @@ import type { Session, User } from 'better-auth' import { defineStore } from 'pinia' -import { computed, nextTick, ref, watch } from 'vue' +import { computed, ref, watch } from 'vue' import { client } from '../composables/api' -import { fetchSession } from '../libs/auth' -import { useConsciousnessStore } from './modules/consciousness' -import { useProvidersStore } from './providers' +/** + * Auth store — holds identity state and credits. + * + * This store has no dependency on `stores/providers`, which allows + * `providers` to safely depend on it without creating a circular import. + */ export const useAuthStore = defineStore('auth', () => { const user = ref() const session = ref() @@ -18,16 +21,6 @@ export const useAuthStore = defineStore('auth', () => { const isLoginOpen = ref(false) - const initialized = ref(false) - const initialize = () => { - if (initialized.value) - return - - fetchSession().catch(() => {}) - - initialized.value = true - } - const updateCredits = async () => { if (!isAuthenticated.value) return @@ -38,33 +31,15 @@ export const useAuthStore = defineStore('auth', () => { } } - // Get store references once - const providersStore = useProvidersStore() - const consciousnessStore = useConsciousnessStore() - watch(isAuthenticated, async (val) => { if (val) { updateCredits() - - // Automatically enable official provider when authenticated - const officialProviderId = 'official-provider' - providersStore.forceProviderConfigured(officialProviderId) - consciousnessStore.activeProvider = officialProviderId - await nextTick() - try { - await consciousnessStore.loadModelsForProvider(officialProviderId) - } - catch (err) { - console.error('error loading models for official provider', err) - } } else { credits.value = 0 } }, { immediate: true }) - initialize() - return { user, userId, diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 1b6a75d6d..a7111498c 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -48,13 +48,14 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { listProviders as listDefinedProviders } from '../libs/providers' -import { SERVER_URL } from '../libs/server' -import { useAuthStore } from '../stores/auth' +import { getProviderValidationIntervalMs } from '../libs/providers/validators/run' import { getKokoroWorker } from '../workers/kokoro' import { getDefaultKokoroModel, KOKORO_MODELS, kokoroModelsToModelInfo } from '../workers/kokoro/constants' +import { useAuthStore } from './auth' import { createAliyunNLSProvider as createAliyunNlsStreamProvider } from './providers/aliyun/stream-transcription' import { convertProviderDefinitionsToMetadata } from './providers/converters' import { models as elevenLabsModels } from './providers/elevenlabs/list-models' +import { createOfficialProviders, OFFICIAL_PROVIDER_IDS } from './providers/official' import { buildOpenAICompatibleProvider } from './providers/openai-compatible-builder' import { buildOpenRouterAudioSpeechProvider } from './providers/openrouter/audio-speech' import { createWebSpeechAPIProvider } from './providers/web-speech-api' @@ -159,6 +160,11 @@ export interface ProviderMetadata { valid: boolean }> } + /** + * If true, the provider does not require user-provided credentials (e.g. API keys). + * Used for official/built-in providers that authenticate via session. + */ + requiresCredentials?: boolean transcriptionFeatures?: { supportsGenerate: boolean supportsStreamOutput: boolean @@ -249,44 +255,35 @@ export const useProvidersStore = defineStore('providers', () => { } // Centralized provider metadata with provider factory functions + const authState = useAuthStore() const providerMetadata: Record = { - 'official-provider': { - id: 'official-provider', - order: -1, - category: 'chat', - tasks: ['text-generation'], - nameKey: 'settings.pages.providers.provider.official.title', - name: 'Official Provider', - descriptionKey: 'settings.pages.providers.provider.official.description', - description: 'Official AI provider by AIRI.', - icon: 'i-solar:star-bold-duotone', - createProvider: async (_config) => { - const authStore = useAuthStore() - if (!authStore.isAuthenticated) { - throw new Error('User is not authenticated') - } - return createOpenAI('', `${SERVER_URL}/v1/`) - }, + ...createOfficialProviders(() => authState.isAuthenticated), + 'speech-noop': { + id: 'speech-noop', + category: 'speech', + tasks: ['text-to-speech', 'tts'], + nameKey: 'settings.pages.providers.provider.speech-noop.title', + name: 'None', + descriptionKey: 'settings.pages.providers.provider.speech-noop.description', + description: 'No speech output.', + icon: 'i-solar:volume-cross-bold-duotone', + defaultOptions: () => ({}), + createProvider: async () => ({ + speech: () => ({ + baseURL: 'http://speech-noop.invalid/v1/', + model: 'noop', + }), + }), capabilities: { - listModels: async () => { - return [ - { - id: 'gpt-4o', - name: 'GPT-4o', - provider: 'official-provider', - }, - ] - }, + listModels: async () => [], + listVoices: async () => [], }, validators: { - validateProviderConfig: () => { - const authStore = useAuthStore() - return { - errors: [], - reason: '', - valid: authStore.isAuthenticated, - } - }, + validateProviderConfig: () => ({ + errors: [], + reason: '', + valid: true, + }), }, }, 'app-local-audio-speech': buildOpenAICompatibleProvider({ @@ -1737,10 +1734,11 @@ export const useProvidersStore = defineStore('providers', () => { } } - // Keep only legacy ASR/TTS providers as hand-written metadata. + // Keep only legacy ASR/TTS providers and official providers as hand-written metadata. // All other categories are sourced from unified definitions in libs/providers. for (const [providerId, existing] of Object.entries(providerMetadata)) { - if (existing.category !== 'speech' && existing.category !== 'transcription') { + if (existing.category !== 'speech' && existing.category !== 'transcription' + && !(OFFICIAL_PROVIDER_IDS as readonly string[]).includes(providerId)) { delete providerMetadata[providerId] } } @@ -1867,9 +1865,8 @@ export const useProvidersStore = defineStore('providers', () => { } // Must run AFTER runtime state is created so forceProviderConfigured can set isConfigured - if (providerId === 'official-provider') { - const authStore = useAuthStore() - if (authStore.isAuthenticated) { + if ((OFFICIAL_PROVIDER_IDS as readonly string[]).includes(providerId)) { + if (authState.isAuthenticated) { forceProviderConfigured(providerId) } } @@ -1919,8 +1916,7 @@ export const useProvidersStore = defineStore('providers', () => { watch(providerCredentials, updateConfigurationStatus, { deep: true, immediate: true }) startPeriodicRuntimeValidation() - const authStore = useAuthStore() - watch(() => authStore.isAuthenticated, updateConfigurationStatus) + watch(() => authState.isAuthenticated, updateConfigurationStatus) // Available providers (only those that are properly configured) const availableProviders = computed(() => Object.keys(providerMetadata).filter(providerId => providerRuntimeState.value[providerId]?.isConfigured)) @@ -1979,14 +1975,14 @@ export const useProvidersStore = defineStore('providers', () => { // Function to fetch models for a specific provider async function fetchModelsForProvider(providerId: string) { - const config = providerCredentials.value[providerId] - if (!config) - return [] - const metadata = providerMetadata[providerId] if (!metadata) return [] + const config = providerCredentials.value[providerId] + if (!config && metadata.requiresCredentials !== false) + return [] + const runtimeState = providerRuntimeState.value[providerId] if (runtimeState) { runtimeState.isLoadingModels = true @@ -1994,7 +1990,7 @@ export const useProvidersStore = defineStore('providers', () => { } try { - const models = metadata.capabilities.listModels ? await metadata.capabilities.listModels(config) : [] + const models = metadata.capabilities.listModels ? await metadata.capabilities.listModels(config || {}) : [] // Transform and store the models if (runtimeState) { @@ -2129,14 +2125,15 @@ export const useProvidersStore = defineStore('providers', () => { if (!metadata) throw new Error(`Provider metadata for ${providerId} not found`) - // Web Speech API doesn't require credentials - use empty config + // Providers that don't require credentials use empty config let config = providerCredentials.value[providerId] - if (!config && providerId === 'browser-web-speech-api') { - config = getDefaultProviderConfig(providerId) + const noCredentials = metadata.requiresCredentials === false || providerId === 'browser-web-speech-api' + if (!config && noCredentials) { + config = getDefaultProviderConfig(providerId) || {} providerCredentials.value[providerId] = config } - if (!config && providerId !== 'browser-web-speech-api') + if (!config && !noCredentials) throw new Error(`Provider credentials for ${providerId} not found`) try { diff --git a/packages/stage-ui/src/stores/providers/official.ts b/packages/stage-ui/src/stores/providers/official.ts new file mode 100644 index 000000000..cd55231b9 --- /dev/null +++ b/packages/stage-ui/src/stores/providers/official.ts @@ -0,0 +1,179 @@ +import type { ProviderMetadata } from '../providers' + +import { createOpenAI } from '@xsai-ext/providers/create' + +import { SERVER_URL } from '../../libs/server' + +const OFFICIAL_ICON = 'i-solar:star-bold-duotone' + +function withCredentials() { + return (input: RequestInfo | URL, init?: RequestInit) => { + return globalThis.fetch(input, { + ...init, + credentials: 'include', + }) + } +} + +function createOfficialOpenAIProvider() { + return createOpenAI('', `${SERVER_URL}/api/v1/`) +} + +export const OFFICIAL_PROVIDER_IDS = [ + 'official-provider', + 'official-provider-speech', + 'official-provider-transcription', +] as const + +/** + * Factory that creates official provider metadata. + * Accepts a lazy auth getter to avoid circular dependency: + * official.ts -> auth.ts -> providers.ts -> official.ts + */ +export function createOfficialProviders(getIsAuthenticated: () => boolean): Record { + function assertAuthenticated() { + if (!getIsAuthenticated()) { + throw new Error('User is not authenticated') + } + } + + function validateAuth() { + return { + errors: [], + reason: '', + valid: getIsAuthenticated(), + } + } + + return { + 'official-provider': { + id: 'official-provider', + order: -1, + category: 'chat', + tasks: ['text-generation'], + nameKey: 'settings.pages.providers.provider.official.title', + name: 'Official Provider', + descriptionKey: 'settings.pages.providers.provider.official.description', + description: 'Official AI provider by AIRI.', + icon: OFFICIAL_ICON, + requiresCredentials: false, + createProvider: async (_config) => { + assertAuthenticated() + const provider = createOfficialOpenAIProvider() + + const originalChat = provider.chat.bind(provider) + provider.chat = (model: string) => { + const result = originalChat(model) + result.fetch = withCredentials() + return result + } + + return provider + }, + capabilities: { + listModels: async () => [ + { + id: 'auto', + name: 'Auto', + provider: 'official-provider', + description: 'Automatically routed by AI Gateway', + }, + ], + }, + validators: { + validateProviderConfig: () => validateAuth(), + }, + }, + + 'official-provider-speech': { + id: 'official-provider-speech', + order: -1, + category: 'speech', + tasks: ['text-to-speech'], + nameKey: 'settings.pages.providers.provider.official.speech-title', + name: 'Official Speech Provider', + descriptionKey: 'settings.pages.providers.provider.official.speech-description', + description: 'Official text-to-speech provider by AIRI.', + icon: OFFICIAL_ICON, + requiresCredentials: false, + createProvider: async (_config) => { + assertAuthenticated() + const provider = createOfficialOpenAIProvider() + + const originalSpeech = provider.speech.bind(provider) + provider.speech = (model: string) => { + const result = originalSpeech(model) + result.fetch = withCredentials() + return result + } + + return provider + }, + capabilities: { + listModels: async () => [ + { + id: 'auto', + name: 'Auto', + provider: 'official-provider-speech', + description: 'Automatically routed by AI Gateway', + }, + ], + listVoices: async () => [ + { id: 'alloy', name: 'Alloy', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] }, + { id: 'echo', name: 'Echo', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] }, + { id: 'fable', name: 'Fable', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] }, + { id: 'onyx', name: 'Onyx', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] }, + { id: 'nova', name: 'Nova', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] }, + { id: 'shimmer', name: 'Shimmer', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] }, + ], + }, + validators: { + validateProviderConfig: () => validateAuth(), + }, + }, + + 'official-provider-transcription': { + id: 'official-provider-transcription', + order: -1, + category: 'transcription', + tasks: ['speech-to-text', 'asr'], + nameKey: 'settings.pages.providers.provider.official.transcription-title', + name: 'Official Transcription Provider', + descriptionKey: 'settings.pages.providers.provider.official.transcription-description', + description: 'Official speech-to-text provider by AIRI.', + icon: OFFICIAL_ICON, + requiresCredentials: false, + transcriptionFeatures: { + supportsGenerate: true, + supportsStreamOutput: false, + supportsStreamInput: false, + }, + createProvider: async (_config) => { + assertAuthenticated() + const provider = createOfficialOpenAIProvider() + + const originalTranscription = provider.transcription.bind(provider) + provider.transcription = (model: string) => { + const result = originalTranscription(model) + result.fetch = withCredentials() + return result + } + + return provider + }, + capabilities: { + listModels: async () => [ + { + id: 'auto', + name: 'Auto', + provider: 'official-provider-transcription', + description: 'Automatically routed by AI Gateway', + }, + ], + }, + validators: { + validateProviderConfig: () => validateAuth(), + }, + }, + } +}