From 87baf622c989b3f5f25a35c6119c7ec8b493a1b1 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 1 Jul 2026 21:43:09 +0800 Subject: [PATCH] feat(server): add official provider catalog management --- apps/server/drizzle/0017_nappy_dagger.sql | 59 + apps/server/drizzle/meta/0017_snapshot.json | 3469 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 + apps/server/src/app.test.ts | 1 + apps/server/src/app.ts | 21 + .../routes/admin/official-catalog/index.ts | 186 + .../admin/official-catalog/route.test.ts | 156 + .../v1/operations/chat-completions/index.ts | 24 +- .../v1/operations/speech-catalog/index.ts | 23 +- .../v1/operations/speech-generation/index.ts | 1 + .../server/src/routes/openai/v1/route.test.ts | 361 +- apps/server/src/routes/openai/v1/types.ts | 2 + apps/server/src/schemas/index.ts | 1 + apps/server/src/schemas/official-catalog.ts | 101 + .../domain/official-catalog/index.test.ts | 208 + .../services/domain/official-catalog/index.ts | 416 ++ .../official-catalog/provider-voices.ts | 65 + .../services/domain/openai-speech/index.ts | 6 + apps/ui-admin/src/App.vue | 2 + apps/ui-admin/src/main.ts | 4 + apps/ui-admin/src/modules/api.ts | 100 + .../src/pages/ProviderCatalogPage.vue | 219 ++ apps/ui-admin/src/pages/TtsCatalogPage.vue | 281 ++ .../2026-07-01-official-provider-catalog.md | 168 + ...26-07-01-official-provider-catalog-prd.xml | 303 ++ 25 files changed, 6170 insertions(+), 14 deletions(-) create mode 100644 apps/server/drizzle/0017_nappy_dagger.sql create mode 100644 apps/server/drizzle/meta/0017_snapshot.json create mode 100644 apps/server/src/routes/admin/official-catalog/index.ts create mode 100644 apps/server/src/routes/admin/official-catalog/route.test.ts create mode 100644 apps/server/src/schemas/official-catalog.ts create mode 100644 apps/server/src/services/domain/official-catalog/index.test.ts create mode 100644 apps/server/src/services/domain/official-catalog/index.ts create mode 100644 apps/server/src/services/domain/official-catalog/provider-voices.ts create mode 100644 apps/ui-admin/src/pages/ProviderCatalogPage.vue create mode 100644 apps/ui-admin/src/pages/TtsCatalogPage.vue create mode 100644 docs/superpowers/plans/2026-07-01-official-provider-catalog.md create mode 100644 docs/superpowers/specs/2026-07-01-official-provider-catalog-prd.xml diff --git a/apps/server/drizzle/0017_nappy_dagger.sql b/apps/server/drizzle/0017_nappy_dagger.sql new file mode 100644 index 000000000..12f100c09 --- /dev/null +++ b/apps/server/drizzle/0017_nappy_dagger.sql @@ -0,0 +1,59 @@ +CREATE TABLE "official_provider_alias_routes" ( + "id" text PRIMARY KEY NOT NULL, + "alias_id" text NOT NULL, + "router_model_id" text NOT NULL, + "pool" text DEFAULT 'primary' NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "weight" integer DEFAULT 1 NOT NULL, + "display_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "official_provider_aliases" ( + "id" text PRIMARY KEY NOT NULL, + "surface" text NOT NULL, + "alias_id" text NOT NULL, + "display_name" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "display_order" integer DEFAULT 0 NOT NULL, + "fallback_enabled" boolean DEFAULT true NOT NULL, + "load_balancing_enabled" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "official_tts_models" ( + "id" text PRIMARY KEY NOT NULL, + "router_model_id" text NOT NULL, + "provider" text NOT NULL, + "display_name" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "display_order" integer DEFAULT 0 NOT NULL, + "last_synced_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "official_tts_voices" ( + "id" text PRIMARY KEY NOT NULL, + "tts_model_id" text NOT NULL, + "provider_voice_id" text NOT NULL, + "display_name" text NOT NULL, + "enabled" boolean DEFAULT false NOT NULL, + "display_order" integer DEFAULT 0 NOT NULL, + "languages" jsonb DEFAULT '[]'::jsonb NOT NULL, + "labels" jsonb DEFAULT '{}'::jsonb NOT NULL, + "preview_audio_url" text, + "source" text DEFAULT 'provider-sync' NOT NULL, + "last_synced_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "official_provider_alias_routes" ADD CONSTRAINT "official_provider_alias_routes_alias_id_official_provider_aliases_id_fk" FOREIGN KEY ("alias_id") REFERENCES "public"."official_provider_aliases"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "official_tts_voices" ADD CONSTRAINT "official_tts_voices_tts_model_id_official_tts_models_id_fk" FOREIGN KEY ("tts_model_id") REFERENCES "public"."official_tts_models"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "official_provider_alias_routes_alias_model_pool_uidx" ON "official_provider_alias_routes" USING btree ("alias_id","router_model_id","pool");--> statement-breakpoint +CREATE UNIQUE INDEX "official_provider_aliases_surface_alias_uidx" ON "official_provider_aliases" USING btree ("surface","alias_id");--> statement-breakpoint +CREATE UNIQUE INDEX "official_tts_models_router_model_uidx" ON "official_tts_models" USING btree ("router_model_id");--> statement-breakpoint +CREATE UNIQUE INDEX "official_tts_voices_model_voice_uidx" ON "official_tts_voices" USING btree ("tts_model_id","provider_voice_id"); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0017_snapshot.json b/apps/server/drizzle/meta/0017_snapshot.json new file mode 100644 index 000000000..fbe6cad34 --- /dev/null +++ b/apps/server/drizzle/meta/0017_snapshot.json @@ -0,0 +1,3469 @@ +{ + "id": "5b4c6661-809a-4b03-8e48-b1c17d3e6ab3", + "prevId": "e5ca3143-6f8a-46c8-8f82-114d37d6c475", + "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.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "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": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "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": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "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 + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_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": {}, + "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": {}, + "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": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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.flux_transaction": { + "name": "flux_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_before": { + "name": "balance_before", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flux_tx_user_id_idx": { + "name": "flux_tx_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_created_at_idx": { + "name": "flux_tx_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_user_request_uniq": { + "name": "flux_tx_user_request_uniq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "request_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": "bigint", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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": "bigint", + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.official_provider_alias_routes": { + "name": "official_provider_alias_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alias_id": { + "name": "alias_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "router_model_id": { + "name": "router_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pool": { + "name": "pool", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'primary'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "display_order": { + "name": "display_order", + "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()" + } + }, + "indexes": { + "official_provider_alias_routes_alias_model_pool_uidx": { + "name": "official_provider_alias_routes_alias_model_pool_uidx", + "columns": [ + { + "expression": "alias_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "router_model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pool", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "official_provider_alias_routes_alias_id_official_provider_aliases_id_fk": { + "name": "official_provider_alias_routes_alias_id_official_provider_aliases_id_fk", + "tableFrom": "official_provider_alias_routes", + "tableTo": "official_provider_aliases", + "columnsFrom": [ + "alias_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.official_provider_aliases": { + "name": "official_provider_aliases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alias_id": { + "name": "alias_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fallback_enabled": { + "name": "fallback_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "load_balancing_enabled": { + "name": "load_balancing_enabled", + "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()" + } + }, + "indexes": { + "official_provider_aliases_surface_alias_uidx": { + "name": "official_provider_aliases_surface_alias_uidx", + "columns": [ + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "alias_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.official_tts_models": { + "name": "official_tts_models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "router_model_id": { + "name": "router_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_synced_at": { + "name": "last_synced_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": { + "official_tts_models_router_model_uidx": { + "name": "official_tts_models_router_model_uidx", + "columns": [ + { + "expression": "router_model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.official_tts_voices": { + "name": "official_tts_voices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tts_model_id": { + "name": "tts_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_voice_id": { + "name": "provider_voice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "languages": { + "name": "languages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "preview_audio_url": { + "name": "preview_audio_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'provider-sync'" + }, + "last_synced_at": { + "name": "last_synced_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": { + "official_tts_voices_model_voice_uidx": { + "name": "official_tts_voices_model_voice_uidx", + "columns": [ + { + "expression": "tts_model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_voice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "official_tts_voices_tts_model_id_official_tts_models_id_fk": { + "name": "official_tts_voices_tts_model_id_official_tts_models_id_fk", + "tableFrom": "official_tts_voices", + "tableTo": "official_tts_models", + "columnsFrom": [ + "tts_model_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_events": { + "name": "product_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_events_feature_action_created_at_idx": { + "name": "product_events_feature_action_created_at_idx", + "columns": [ + { + "expression": "feature", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_events_user_id_created_at_idx": { + "name": "product_events_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_events_created_at_idx": { + "name": "product_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_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": {}, + "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 + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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 + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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": "boolean", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "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 + }, + "public.voice_packs": { + "name": "voice_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": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voice_id": { + "name": "voice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upstream_voice_id": { + "name": "upstream_voice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tts_model_id": { + "name": "tts_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 3a082fea9..d9a0ecc21 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1782847276369, "tag": "0016_tired_dagger", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1782912836523, + "tag": "0017_nappy_dagger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts index b755ea119..2aa864e45 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -52,6 +52,7 @@ function createTestDeps() { ttsMeter: {} as any, requestLogService: {} as any, voicePackService: {} as any, + officialCatalogService: {} as any, productEventService: { track: vi.fn(async () => undefined), countDistinctUsersByFeature: vi.fn(async () => []), diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 3e9f80362..f2dd40335 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -16,6 +16,7 @@ import type { ChatService } from './services/domain/chats' import type { FluxService } from './services/domain/flux' import type { FluxTransactionService } from './services/domain/flux-transaction' import type { LlmRouterService } from './services/domain/llm-router' +import type { OfficialCatalogService } from './services/domain/official-catalog' import type { ProductEventService } from './services/domain/product-events' import type { ProviderService } from './services/domain/providers' import type { RequestLogService } from './services/domain/request-log' @@ -57,6 +58,7 @@ import { createAdminRoutes } from './routes/admin' import { createAdminUiRoutes } from './routes/admin-ui' import { createAdminRouterConfigRoutes } from './routes/admin/config/router' import { createAdminFluxGrantsRoutes } from './routes/admin/flux-grants' +import { createAdminOfficialCatalogRoutes } from './routes/admin/official-catalog' import { createAdminUsersRoutes } from './routes/admin/users' import { createAdminVoicePackRoutes } from './routes/admin/voice-packs' import { createAudioSpeechWsHandlers } from './routes/audio-speech-ws' @@ -82,6 +84,7 @@ import { createChatService } from './services/domain/chats' import { createFluxService } from './services/domain/flux' import { createFluxTransactionService } from './services/domain/flux-transaction' import { createConcurrencyLedger, createConfigSyncSubscriber, createLlmRouterService } from './services/domain/llm-router' +import { createOfficialCatalogService } from './services/domain/official-catalog' import { createProductEventService } from './services/domain/product-events' import { createProviderService } from './services/domain/providers' import { createRequestLogService } from './services/domain/request-log' @@ -117,6 +120,7 @@ interface AppDeps { otel: OtelInstance | null userDeletionService: UserDeletionService llmRouter: LlmRouterService + officialCatalogService: OfficialCatalogService } export async function buildApp(deps: AppDeps) { @@ -249,6 +253,7 @@ export async function buildApp(deps: AppDeps) { productEventService: deps.productEventService, ttsMeter: deps.ttsMeter, llmRouter: deps.llmRouter, + officialCatalogService: deps.officialCatalogService, voicePackService: deps.voicePackService, genAi: deps.otel?.genAi, revenue: deps.otel?.revenue, @@ -414,6 +419,15 @@ export async function buildApp(deps: AppDeps) { service: deps.voicePackService, })) + /** + * Admin official provider catalog curation routes. + */ + .route('/api/admin/official-catalog', createAdminOfficialCatalogRoutes({ + configKV: deps.configKV, + llmRouter: deps.llmRouter, + service: deps.officialCatalogService, + })) + /** * Admin LLM router config seeding/patching. Single entry point for * writing `LLM_ROUTER_CONFIG`, `UNSPEECH_UPSTREAM`, and the @@ -661,6 +675,11 @@ export async function createApp() { build: ({ dependsOn }) => createVoicePackService(dependsOn.db), }) + const officialCatalogService = injeca.provide('services:officialCatalog', { + dependsOn: { db }, + build: ({ dependsOn }) => createOfficialCatalogService(dependsOn.db), + }) + const billingService = injeca.provide('services:billing', { dependsOn: { db, redis, configKV, otel }, build: ({ dependsOn }) => createBillingService(dependsOn.db, dependsOn.redis, dependsOn.configKV, dependsOn.otel?.revenue), @@ -771,6 +790,7 @@ export async function createApp() { otel, userDeletionService, llmRouter, + officialCatalogService, ttsConcurrencyLedger, }) // Register the cluster-wide ObservableGauges for sessions / users. Each @@ -815,6 +835,7 @@ export async function createApp() { otel: resolved.otel, userDeletionService: resolved.userDeletionService, llmRouter: resolved.llmRouter, + officialCatalogService: resolved.officialCatalogService, }) logger.withFields({ hostname: resolved.env.HOST, port: resolved.env.PORT }).log('Server started') diff --git a/apps/server/src/routes/admin/official-catalog/index.ts b/apps/server/src/routes/admin/official-catalog/index.ts new file mode 100644 index 000000000..6739514f7 --- /dev/null +++ b/apps/server/src/routes/admin/official-catalog/index.ts @@ -0,0 +1,186 @@ +import type { Context } from 'hono' +import type { GenericSchema, InferOutput } from 'valibot' + +import type { ConfigKVService } from '../../../services/adapters/config-kv' +import type { LlmRouterService } from '../../../services/domain/llm-router' +import type { OfficialCatalogService } from '../../../services/domain/official-catalog' +import type { HonoEnv } from '../../../types/hono' + +import { Hono } from 'hono' +import { any, array, boolean, integer, maxLength, minValue, nullable, number, object, optional, picklist, pipe, record, safeParse, string } from 'valibot' + +import { adminGuard } from '../../../middlewares/admin-guard' +import { authGuard } from '../../../middlewares/auth' +import { normalizeProviderVoiceForCatalog } from '../../../services/domain/official-catalog/provider-voices' +import { createBadRequestError, createNotFoundError } from '../../../utils/error' + +const SurfaceSchema = picklist(['llm', 'asr']) + +const AliasUpdateBodySchema = object({ + displayName: optional(pipe(string(), maxLength(120))), + enabled: optional(boolean()), + displayOrder: optional(pipe(number(), integer(), minValue(0))), + fallbackEnabled: optional(boolean()), + loadBalancingEnabled: optional(boolean()), +}) + +const AliasRouteUpdateBodySchema = object({ + enabled: optional(boolean()), + pool: optional(picklist(['primary', 'fallback'])), + weight: optional(pipe(number(), integer(), minValue(1))), + displayOrder: optional(pipe(number(), integer(), minValue(0))), +}) + +const TtsModelUpdateBodySchema = object({ + displayName: optional(pipe(string(), maxLength(120))), + enabled: optional(boolean()), + displayOrder: optional(pipe(number(), integer(), minValue(0))), +}) + +const LanguageSchema = object({ + code: pipe(string(), maxLength(32)), + title: optional(pipe(string(), maxLength(80))), +}) + +const TtsVoiceUpdateBodySchema = object({ + displayName: optional(pipe(string(), maxLength(120))), + enabled: optional(boolean()), + displayOrder: optional(pipe(number(), integer(), minValue(0))), + languages: optional(array(LanguageSchema)), + labels: optional(record(string(), any())), + previewAudioUrl: optional(nullable(pipe(string(), maxLength(2048)))), +}) + +const TtsVoiceSyncBodySchema = object({ + routerModelId: pipe(string(), maxLength(160)), +}) + +export interface AdminOfficialCatalogRoutesDeps { + configKV: ConfigKVService + llmRouter: LlmRouterService + service: OfficialCatalogService +} + +function parseIssues(issues: Array<{ path?: Array<{ key: unknown }>, message: string }>) { + return issues.map(i => ({ + path: i.path?.map(p => p.key).join('.'), + message: i.message, + })) +} + +async function readJson(c: Context): Promise { + const raw = await c.req.json().catch(() => null) + if (raw == null) + throw createBadRequestError('Request body must be JSON', 'INVALID_BODY') + return raw +} + +async function readBody(c: Context, schema: S): Promise> { + const parsed = safeParse(schema, await readJson(c)) + if (!parsed.success) + throw createBadRequestError('Invalid request body', 'INVALID_BODY', parseIssues(parsed.issues)) + return parsed.output +} + +async function syncAliasesFromConfig(deps: AdminOfficialCatalogRoutesDeps, surface: 'llm' | 'asr') { + const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG') + if (surface === 'llm') { + const defaultModel = await deps.configKV.getOrThrow('DEFAULT_CHAT_MODEL') + const modelIds = [ + defaultModel, + ...Object.keys(config.llm.models).sort().filter(modelId => modelId !== defaultModel), + ] + return await deps.service.syncAliasesFromRouterConfig({ surface, modelIds }) + } + + return await deps.service.syncAliasesFromRouterConfig({ + surface, + modelIds: Object.keys(config.asr?.models ?? {}).sort(), + }) +} + +async function syncTtsModelsFromConfig(deps: AdminOfficialCatalogRoutesDeps) { + const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG') + return await deps.service.syncTtsModelsFromRouterConfig({ + models: Object.fromEntries( + Object.entries(config.tts.models).map(([routerModelId, model]) => [ + routerModelId, + { provider: model.provider }, + ]), + ), + }) +} + +/** + * Admin routes for the official provider catalog. + * + * Mounted at `/api/admin/official-catalog`. These routes curate only the + * product catalog state: enabled flags, display order, aliases, and TTS voice + * metadata. Real upstream URLs, credentials, and provider fallback config stay + * owned by `LLM_ROUTER_CONFIG`. + */ +export function createAdminOfficialCatalogRoutes(deps: AdminOfficialCatalogRoutesDeps) { + return new Hono() + .use('*', authGuard) + .use('*', adminGuard) + .get('/aliases', async (c) => { + const rawSurface = c.req.query('surface') + const parsed = rawSurface ? safeParse(SurfaceSchema, rawSurface) : null + if (parsed && !parsed.success) + throw createBadRequestError('Invalid surface', 'INVALID_QUERY', parseIssues(parsed.issues)) + + return c.json(await deps.service.listAliases(parsed?.success ? parsed.output : undefined)) + }) + .post('/aliases/sync', async (c) => { + const body = await readBody(c, object({ surface: SurfaceSchema })) + return c.json({ aliases: await syncAliasesFromConfig(deps, body.surface) }) + }) + .patch('/aliases/:id', async (c) => { + const body = await readBody(c, AliasUpdateBodySchema) + const updated = await deps.service.updateAlias(c.req.param('id'), body) + if (!updated) + throw createNotFoundError('Official alias not found') + return c.json(updated) + }) + .patch('/alias-routes/:id', async (c) => { + const body = await readBody(c, AliasRouteUpdateBodySchema) + const updated = await deps.service.updateAliasRoute(c.req.param('id'), body) + if (!updated) + throw createNotFoundError('Official alias route not found') + return c.json(updated) + }) + .get('/tts/models', async (c) => { + return c.json(await deps.service.listTtsModels()) + }) + .post('/tts/models/sync', async (c) => { + return c.json({ models: await syncTtsModelsFromConfig(deps) }) + }) + .patch('/tts/models/:id', async (c) => { + const body = await readBody(c, TtsModelUpdateBodySchema) + const updated = await deps.service.updateTtsModel(c.req.param('id'), body) + if (!updated) + throw createNotFoundError('Official TTS model not found') + return c.json(updated) + }) + .get('/tts/voices', async (c) => { + const routerModelId = c.req.query('model') + if (!routerModelId) + throw createBadRequestError('model query is required', 'MISSING_MODEL') + return c.json(await deps.service.listTtsVoices(routerModelId)) + }) + .post('/tts/voices/sync', async (c) => { + const body = await readBody(c, TtsVoiceSyncBodySchema) + await syncTtsModelsFromConfig(deps) + const providerVoices = await deps.llmRouter.listTtsVoices(body.routerModelId) + const voices = providerVoices.map(normalizeProviderVoiceForCatalog).filter(voice => voice != null) + const synced = await deps.service.syncTtsVoices({ routerModelId: body.routerModelId, voices }) + return c.json({ voices: synced, syncedCount: synced.length }) + }) + .patch('/tts/voices/:id', async (c) => { + const body = await readBody(c, TtsVoiceUpdateBodySchema) + const updated = await deps.service.updateTtsVoice(c.req.param('id'), body) + if (!updated) + throw createNotFoundError('Official TTS voice not found') + return c.json(updated) + }) +} diff --git a/apps/server/src/routes/admin/official-catalog/route.test.ts b/apps/server/src/routes/admin/official-catalog/route.test.ts new file mode 100644 index 000000000..72167ec28 --- /dev/null +++ b/apps/server/src/routes/admin/official-catalog/route.test.ts @@ -0,0 +1,156 @@ +import type { ConfigKVService } from '../../../services/adapters/config-kv' +import type { LlmRouterService } from '../../../services/domain/llm-router' +import type { OfficialCatalogService } from '../../../services/domain/official-catalog' +import type { HonoEnv } from '../../../types/hono' + +import { Hono } from 'hono' +import { describe, expect, it, vi } from 'vitest' + +import { createAdminOfficialCatalogRoutes } from '.' +import { ApiError } from '../../../utils/error' + +interface MockUser { + id: string + email: string + role?: string | null +} + +const ADMIN: MockUser = { id: 'admin-1', email: 'admin@example.com', role: 'admin' } + +function createConfigKV(): ConfigKVService { + return { + getOrThrow: vi.fn(async (key: string) => { + if (key === 'DEFAULT_CHAT_MODEL') + return 'chat-default' + if (key === 'LLM_ROUTER_CONFIG') { + return { + llm: { models: { 'chat-default': { upstreams: [] } } }, + tts: { models: { 'microsoft/v1': { provider: 'azure', upstreams: [] } } }, + asr: { models: { auto: { provider: 'aliyun-nls', upstreams: [] } } }, + } + } + throw new ApiError(503, 'CONFIG_NOT_SET', 'Service configuration is incomplete') + }), + getOptional: vi.fn(async () => null), + get: vi.fn(), + set: vi.fn(), + } as unknown as ConfigKVService +} + +function createLlmRouter(): LlmRouterService { + return { + route: vi.fn(), + routeTts: vi.fn(), + listTtsVoices: vi.fn(async () => [ + { id: 'en-US-AvaMultilingualNeural', name: 'Ava', previewUrl: 'https://example.com/ava.mp3' }, + ]), + invalidateConfig: vi.fn(), + invalidateTtsVoicesCache: vi.fn(), + } as unknown as LlmRouterService +} + +function createService(): OfficialCatalogService { + return { + syncAliasesFromRouterConfig: vi.fn(async () => []), + listAliases: vi.fn(async () => []), + resolveEnabledAlias: vi.fn(), + updateAlias: vi.fn(async (_id, input) => ({ id: 'alias-1', ...input })), + updateAliasRoute: vi.fn(async (_id, input) => ({ id: 'route-1', ...input })), + syncTtsModelsFromRouterConfig: vi.fn(async () => []), + listTtsModels: vi.fn(async () => []), + listEnabledTtsModels: vi.fn(async () => []), + updateTtsModel: vi.fn(async (_id, input) => ({ id: 'model-1', ...input })), + assertTtsModelEnabled: vi.fn(), + syncTtsVoices: vi.fn(async (input: Parameters[0]) => input.voices.map((voice, index) => ({ + id: `voice-${index}`, + providerVoiceId: voice.id, + displayName: voice.name ?? voice.id, + enabled: false, + }))), + listTtsVoices: vi.fn(async () => []), + listEnabledTtsVoices: vi.fn(async () => []), + updateTtsVoice: vi.fn(async (_id, input) => ({ id: 'voice-1', ...input })), + assertTtsVoiceEnabled: vi.fn(), + } as unknown as OfficialCatalogService +} + +function createTestApp(input: { + user: MockUser | null + configKV?: ConfigKVService + llmRouter?: LlmRouterService + service?: OfficialCatalogService +}) { + return new Hono() + .use('*', async (c, next) => { + c.set('user', input.user as HonoEnv['Variables']['user']) + await next() + }) + .route('/api/admin/official-catalog', createAdminOfficialCatalogRoutes({ + configKV: input.configKV ?? createConfigKV(), + llmRouter: input.llmRouter ?? createLlmRouter(), + service: input.service ?? createService(), + })) + .onError((err, c) => { + if (err instanceof ApiError) + return c.json({ error: err.errorCode, details: err.details }, err.statusCode) + return c.json({ error: 'internal', message: (err as Error).message }, 500) + }) +} + +function jsonRequest(app: Hono, method: string, path: string, body?: unknown) { + return app.request(path, { + method, + headers: { 'content-type': 'application/json' }, + body: body == null ? undefined : JSON.stringify(body), + }) +} + +describe('admin official catalog routes', () => { + it('returns 401 when unauthenticated', async () => { + const service = createService() + const app = createTestApp({ user: null, service }) + const res = await jsonRequest(app, 'GET', '/api/admin/official-catalog/aliases') + + expect(res.status).toBe(401) + expect(service.listAliases).not.toHaveBeenCalled() + }) + + it('syncs TTS voices from the provider into the official catalog', async () => { + const service = createService() + const llmRouter = createLlmRouter() + const app = createTestApp({ user: ADMIN, service, llmRouter }) + + const res = await jsonRequest(app, 'POST', '/api/admin/official-catalog/tts/voices/sync', { + routerModelId: 'microsoft/v1', + }) + + expect(res.status).toBe(200) + expect(service.syncTtsModelsFromRouterConfig).toHaveBeenCalledWith({ + models: { 'microsoft/v1': { provider: 'azure' } }, + }) + expect(llmRouter.listTtsVoices).toHaveBeenCalledWith('microsoft/v1') + expect(service.syncTtsVoices).toHaveBeenCalledWith({ + routerModelId: 'microsoft/v1', + voices: [{ + id: 'en-US-AvaMultilingualNeural', + name: 'Ava', + languages: undefined, + labels: undefined, + previewAudioUrl: 'https://example.com/ava.mp3', + }], + }) + expect(await res.json()).toMatchObject({ syncedCount: 1 }) + }) + + it('maps missing catalog rows to 404 on update', async () => { + const service = createService() + vi.mocked(service.updateTtsModel).mockResolvedValueOnce(null) + const app = createTestApp({ user: ADMIN, service }) + + const res = await jsonRequest(app, 'PATCH', '/api/admin/official-catalog/tts/models/missing', { + enabled: false, + }) + + expect(res.status).toBe(404) + }) +}) diff --git a/apps/server/src/routes/openai/v1/operations/chat-completions/index.ts b/apps/server/src/routes/openai/v1/operations/chat-completions/index.ts index 061fc8d8c..84036997e 100644 --- a/apps/server/src/routes/openai/v1/operations/chat-completions/index.ts +++ b/apps/server/src/routes/openai/v1/operations/chat-completions/index.ts @@ -40,11 +40,8 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple const billingPolicy = await billing.authorizeChat(input.userId) const body = input.body - let requestModel = typeof body.model === 'string' && body.model.length > 0 ? body.model : 'auto' - - if (requestModel === 'auto') { - requestModel = await deps.configKV.getOrThrow('DEFAULT_CHAT_MODEL') - } + const requestedAlias = typeof body.model === 'string' && body.model.length > 0 ? body.model : 'auto' + const requestModel = await resolveChatModelAlias(deps, requestedAlias) const stream = !!body.stream logger.withFields({ @@ -201,6 +198,23 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple } } +async function resolveChatModelAlias(deps: V1RouteDeps, aliasId: string): Promise { + const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG') + const defaultModel = await deps.configKV.getOrThrow('DEFAULT_CHAT_MODEL') + const modelIds = [ + defaultModel, + ...Object.keys(config.llm.models).sort().filter(modelId => modelId !== defaultModel), + ] + await deps.officialCatalogService.syncAliasesFromRouterConfig({ + surface: 'llm', + modelIds, + }) + + const alias = await deps.officialCatalogService.resolveEnabledAlias('llm', aliasId) + const primary = alias.routes.find(route => route.pool === 'primary') + return (primary ?? alias.routes[0]).routerModelId +} + function streamChatCompletion(input: { deps: V1RouteDeps response: Response diff --git a/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts b/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts index 0b8cbd084..9e07fec60 100644 --- a/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts +++ b/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts @@ -4,6 +4,7 @@ import type { V1RouteDeps } from '../../types' import { useLogger } from '@guiiai/logg' import { ofetch } from 'ofetch' +import { catalogVoiceResponse, normalizeProviderVoiceForCatalog } from '../../../../../services/domain/official-catalog/provider-voices' import { createBadGatewayError, createBadRequestError, createServiceUnavailableError } from '../../../../../utils/error' const VOICE_PACK_MODEL_ID = 'voice-pack' @@ -63,13 +64,19 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp return Response.json({ voices: voicePacks.map(voicePackCatalogVoice), recommended: {} }) } - const voices = await deps.llmRouter.listTtsVoices(model) + await deps.officialCatalogService.assertTtsModelEnabled(model) + const providerVoices = await deps.llmRouter.listTtsVoices(model) + await deps.officialCatalogService.syncTtsVoices({ + routerModelId: model, + voices: providerVoices.map(normalizeProviderVoiceForCatalog).filter(voice => voice != null), + }) + const voices = await deps.officialCatalogService.listEnabledTtsVoices(model) const recommended = (await deps.configKV.getOptional('DEFAULT_TTS_VOICES'))?.[model] ?? {} // Debug level: high-frequency catalog poll from UI selectors, no // billing / user-facing side effect — useful only when debugging // voice-picker drift, never as a permanent audit trail line. logger.withFields({ model, voiceCount: voices.length, voicePackCount: voicePacks.length }).debug('list tts voices') - return Response.json({ voices, recommended }) + return Response.json({ voices: voices.map(catalogVoiceResponse), recommended }) } /** @@ -155,11 +162,19 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp // tolerates `undefined`. `getOrThrow` already throws on missing entries, // so by this line we know `config` is present — the `?.` here is purely // a TS narrowing aid. - const modelIds = Object.keys(config?.tts?.models ?? {}).sort() + await deps.officialCatalogService.syncTtsModelsFromRouterConfig({ + models: Object.fromEntries( + Object.entries(config?.tts?.models ?? {}).map(([routerModelId, model]) => [ + routerModelId, + { provider: model.provider }, + ]), + ), + }) + const models = await deps.officialCatalogService.listEnabledTtsModels() return Response.json({ models: [ { id: VOICE_PACK_MODEL_ID, name: 'Voice Pack', description: 'Server-curated voices' }, - ...modelIds.map(id => ({ id, name: id })), + ...models.map(model => ({ id: model.routerModelId, name: model.displayName })), ], default: defaultModel, }) diff --git a/apps/server/src/routes/openai/v1/operations/speech-generation/index.ts b/apps/server/src/routes/openai/v1/operations/speech-generation/index.ts index dc6544161..e12f8d154 100644 --- a/apps/server/src/routes/openai/v1/operations/speech-generation/index.ts +++ b/apps/server/src/routes/openai/v1/operations/speech-generation/index.ts @@ -17,6 +17,7 @@ export function speechGeneration(deps: V1RouteDeps): GatewayCallback<'speech.gen genAi: deps.genAi, llmRouter: deps.llmRouter, llmTracing: deps.llmTracing, + officialCatalogService: deps.officialCatalogService, productEventService: deps.productEventService, requestLogService: deps.requestLogService, ttsMeter: deps.ttsMeter, diff --git a/apps/server/src/routes/openai/v1/route.test.ts b/apps/server/src/routes/openai/v1/route.test.ts index cca0bb2ef..4f030fa1b 100644 --- a/apps/server/src/routes/openai/v1/route.test.ts +++ b/apps/server/src/routes/openai/v1/route.test.ts @@ -3,6 +3,7 @@ import type { BillingService } from '../../../services/domain/billing/billing-se import type { FluxService } from '../../../services/domain/flux' import type { LlmRouterService } from '../../../services/domain/llm-router' import type { ChatGenerationTrace, TtsGenerationTrace } from '../../../services/domain/llm-tracing' +import type { OfficialCatalogService } from '../../../services/domain/official-catalog' import type { ProductEventService } from '../../../services/domain/product-events' import type { RequestLogService } from '../../../services/domain/request-log' import type { VoicePackService } from '../../../services/domain/voice-packs' @@ -46,6 +47,10 @@ function createMockConfigKV(overrides: Record = {}): ConfigKVServic TTS_DEBT_TTL_SECONDS: 86400, DEFAULT_CHAT_MODEL: 'openai/gpt-5-mini', DEFAULT_TTS_MODEL: 'tts-1', + LLM_ROUTER_CONFIG: { + llm: { models: { 'openai/gpt-5-mini': { upstreams: [] } } }, + tts: { models: {} }, + }, ...overrides, } return { @@ -155,6 +160,143 @@ function createMockVoicePackService(impl?: Partial): VoicePack } as unknown as VoicePackService } +function createMockOfficialCatalogService(impl?: Partial): OfficialCatalogService { + let syncedAliasRoutes: Array<{ + id: string + aliasId: string + routerModelId: string + pool: 'primary' | 'fallback' + enabled: boolean + weight: number + displayOrder: number + createdAt: Date + updatedAt: Date + }> = [] + let syncedModels: Awaited> = [] + const syncedVoicesByModel = new Map>>() + + return { + syncAliasesFromRouterConfig: vi.fn(async (input: Parameters[0]) => { + const { surface, modelIds } = input + syncedAliasRoutes = Array.from(new Set(modelIds)).map((routerModelId, index) => ({ + id: `alias-route-${index}`, + aliasId: 'alias-auto', + routerModelId, + pool: 'primary', + enabled: true, + weight: 1, + displayOrder: index, + createdAt: new Date(), + updatedAt: new Date(), + })) + return [{ + id: 'alias-auto', + surface, + aliasId: 'auto', + displayName: 'Auto', + enabled: true, + displayOrder: 0, + fallbackEnabled: true, + loadBalancingEnabled: false, + createdAt: new Date(), + updatedAt: new Date(), + }] + }), + listAliases: vi.fn(async () => []), + resolveEnabledAlias: vi.fn(async (surface, aliasId) => ({ + id: `alias-${aliasId}`, + surface, + aliasId, + displayName: aliasId, + enabled: true, + displayOrder: 0, + fallbackEnabled: true, + loadBalancingEnabled: false, + createdAt: new Date(), + updatedAt: new Date(), + routes: aliasId === 'auto' + ? syncedAliasRoutes + : [{ + id: `alias-route-${aliasId}`, + aliasId: `alias-${aliasId}`, + routerModelId: aliasId, + pool: 'primary', + enabled: true, + weight: 1, + displayOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + }], + })), + syncTtsModelsFromRouterConfig: vi.fn(async (input: Parameters[0]) => { + const { models } = input + syncedModels = Object.entries(models).sort(([a], [b]) => a.localeCompare(b)).map(([routerModelId, model], index) => ({ + id: `tts-model-${index}`, + routerModelId, + provider: model.provider, + displayName: routerModelId, + enabled: true, + displayOrder: index, + lastSyncedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + })) + return syncedModels + }), + listTtsModels: vi.fn(async () => []), + listEnabledTtsModels: vi.fn(async () => syncedModels), + assertTtsModelEnabled: vi.fn(async routerModelId => ({ + id: 'tts-model-1', + routerModelId, + provider: 'azure', + displayName: routerModelId, + enabled: true, + displayOrder: 0, + lastSyncedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + })), + syncTtsVoices: vi.fn(async (input: Parameters[0]) => { + const { routerModelId, voices } = input + const syncedVoices = voices.map((voice, index) => ({ + id: `tts-voice-${index}`, + ttsModelId: 'tts-model-1', + providerVoiceId: voice.id, + displayName: voice.name ?? voice.id, + enabled: true, + displayOrder: index, + languages: voice.languages ?? [], + labels: voice.labels ?? {}, + previewAudioUrl: voice.previewAudioUrl ?? null, + source: 'provider-sync' as const, + lastSyncedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + })) + syncedVoicesByModel.set(routerModelId, syncedVoices) + return syncedVoices + }), + listTtsVoices: vi.fn(async () => []), + listEnabledTtsVoices: vi.fn(async routerModelId => syncedVoicesByModel.get(routerModelId) ?? []), + assertTtsVoiceEnabled: vi.fn(async (_routerModelId, providerVoiceId) => ({ + id: 'tts-voice-1', + ttsModelId: 'tts-model-1', + providerVoiceId, + displayName: providerVoiceId, + enabled: true, + displayOrder: 0, + languages: [], + labels: {}, + previewAudioUrl: null, + source: 'provider-sync', + lastSyncedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + })), + ...impl, + } as OfficialCatalogService +} + function createTestApp( fluxService: FluxService, configKV: ConfigKVService, @@ -165,6 +307,7 @@ function createTestApp( llmTracing = createMockLlmTracing(), productEventService = createMockProductEventService(), voicePackService = createMockVoicePackService(), + officialCatalogService = createMockOfficialCatalogService(), ) { const { openaiRoutes, audioRoutes } = createV1Routes({ fluxService, @@ -175,6 +318,7 @@ function createTestApp( ttsMeter: ttsMeter ?? createMockTtsMeter(), llmRouter: llmRouter ?? createMockLlmRouter(), voicePackService, + officialCatalogService, genAi: null, revenue: null, rateLimitMetrics: null, @@ -454,7 +598,7 @@ describe('v1CompletionsRoutes', () => { ) }) - it('should pass through non-auto model as-is', async () => { + it('resolves an enabled non-auto model alias through the official catalog', async () => { globalThis.fetch = vi.fn(async () => new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' }, @@ -479,6 +623,76 @@ describe('v1CompletionsRoutes', () => { ) }) + it('rejects disabled LLM aliases before upstream routing', async () => { + const route = vi.fn(async () => new Response('{}', { status: 200 })) + const officialCatalogService = createMockOfficialCatalogService({ + resolveEnabledAlias: vi.fn(async () => { + throw new ApiError(400, 'OFFICIAL_ALIAS_DISABLED', 'Official provider alias is disabled') + }), + }) + const app = createTestApp( + createMockFluxService(), + createMockConfigKV(), + undefined, + undefined, + undefined, + createMockLlmRouter({ route }), + createMockLlmTracing(), + createMockProductEventService(), + createMockVoicePackService(), + officialCatalogService, + ) + + const res = await app.fetch( + new Request('http://localhost/api/v1/openai/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'auto', messages: [] }), + }), + { user: testUser } as any, + ) + + expect(res.status).toBe(400) + const body = await res.json() as { error?: string } + expect(body.error).toBe('OFFICIAL_ALIAS_DISABLED') + expect(route).not.toHaveBeenCalled() + }) + + it('rejects missing LLM aliases before upstream routing', async () => { + const route = vi.fn(async () => new Response('{}', { status: 200 })) + const officialCatalogService = createMockOfficialCatalogService({ + resolveEnabledAlias: vi.fn(async () => { + throw new ApiError(400, 'OFFICIAL_ALIAS_NOT_FOUND', 'Official provider alias is not configured') + }), + }) + const app = createTestApp( + createMockFluxService(), + createMockConfigKV(), + undefined, + undefined, + undefined, + createMockLlmRouter({ route }), + createMockLlmTracing(), + createMockProductEventService(), + createMockVoicePackService(), + officialCatalogService, + ) + + const res = await app.fetch( + new Request('http://localhost/api/v1/openai/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'deepseek', messages: [] }), + }), + { user: testUser } as any, + ) + + expect(res.status).toBe(400) + const body = await res.json() as { error?: string } + expect(body.error).toBe('OFFICIAL_ALIAS_NOT_FOUND') + expect(route).not.toHaveBeenCalled() + }) + it('records Langfuse chat generation with the router-resolved upstream model', async () => { const llmRouter = createMockLlmRouter({ route: vi.fn(async (_req, ctx) => { @@ -540,7 +754,11 @@ describe('v1CompletionsRoutes', () => { it('should return 503 when config keys are missing', async () => { const configKV = createMockConfigKV() - configKV.getOptional = vi.fn(async () => null) + configKV.getOrThrow = vi.fn(async (key: string) => { + if (key === 'LLM_ROUTER_CONFIG') + throw new ApiError(503, 'CONFIG_NOT_SET', 'Service configuration is incomplete') + return createMockConfigKV().getOrThrow(key as never) + }) const app = createTestApp(createMockFluxService(), configKV) @@ -685,6 +903,81 @@ describe('v1CompletionsRoutes', () => { ) }) + it('rejects disabled official TTS models before billing or upstream routing', async () => { + const routeTts = vi.fn(async () => new Response(new Uint8Array([1]), { status: 200 })) + const ttsMeter = createMockTtsMeter() + const officialCatalogService = createMockOfficialCatalogService({ + assertTtsModelEnabled: vi.fn(async () => { + throw new ApiError(400, 'OFFICIAL_MODEL_DISABLED', 'Official TTS model is disabled') + }), + }) + const app = createTestApp( + createMockFluxService(), + createMockConfigKV({ DEFAULT_TTS_MODEL: 'microsoft/v1' }), + undefined, + undefined, + ttsMeter, + createMockLlmRouter({ routeTts }), + createMockLlmTracing(), + createMockProductEventService(), + createMockVoicePackService(), + officialCatalogService, + ) + + const res = await app.fetch( + new Request('http://localhost/api/v1/audio/speech', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'auto', input: 'test', voice: 'alloy' }), + }), + { user: testUser } as any, + ) + + expect(res.status).toBe(400) + const body = await res.json() as { error?: string } + expect(body.error).toBe('OFFICIAL_MODEL_DISABLED') + expect(ttsMeter.assertCanAfford).not.toHaveBeenCalled() + expect(routeTts).not.toHaveBeenCalled() + }) + + it('rejects disabled official TTS voices before billing or upstream routing', async () => { + const routeTts = vi.fn(async () => new Response(new Uint8Array([1]), { status: 200 })) + const ttsMeter = createMockTtsMeter() + const officialCatalogService = createMockOfficialCatalogService({ + assertTtsVoiceEnabled: vi.fn(async () => { + throw new ApiError(400, 'OFFICIAL_VOICE_DISABLED', 'Official TTS voice is disabled') + }), + }) + const app = createTestApp( + createMockFluxService(), + createMockConfigKV({ DEFAULT_TTS_MODEL: 'microsoft/v1' }), + undefined, + undefined, + ttsMeter, + createMockLlmRouter({ routeTts }), + createMockLlmTracing(), + createMockProductEventService(), + createMockVoicePackService(), + officialCatalogService, + ) + + const res = await app.fetch( + new Request('http://localhost/api/v1/audio/speech', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'auto', input: 'test', voice: 'alloy' }), + }), + { user: testUser } as any, + ) + + expect(res.status).toBe(400) + const body = await res.json() as { error?: string } + expect(body.error).toBe('OFFICIAL_VOICE_DISABLED') + expect(officialCatalogService.assertTtsVoiceEnabled).toHaveBeenCalledWith('microsoft/v1', 'alloy') + expect(ttsMeter.assertCanAfford).not.toHaveBeenCalled() + expect(routeTts).not.toHaveBeenCalled() + }) + /** * @example * POST /api/v1/audio/speech { "model": "voice-pack", "voice": "friendly-azure" } @@ -1398,8 +1691,23 @@ describe('v1CompletionsRoutes', () => { ) expect(res.status).toBe(200) - const data = await res.json() as { voices: typeof voices, recommended: Record } - expect(data.voices).toEqual(voices) + const data = await res.json() as { voices: Array>, recommended: Record } + expect(data.voices).toEqual([ + { + id: 'en-US-JennyNeural', + name: 'Jenny', + languages: [], + labels: {}, + previewAudioUrl: null, + }, + { + id: 'en-US-AvaMultilingualNeural', + name: 'Ava', + languages: [], + labels: {}, + previewAudioUrl: null, + }, + ]) expect(data.recommended).toEqual({ 'en-US': 'en-US-AvaMultilingualNeural' }) expect(llmRouter.listTtsVoices).toHaveBeenCalledWith('microsoft/v1') }) @@ -1517,10 +1825,53 @@ describe('v1CompletionsRoutes', () => { expect(res.status).toBe(200) const data = await res.json() as { voices: Array> } expect(data.voices).toEqual([ - { id: 'en-US-AvaMultilingualNeural', name: 'Ava', languages: [{ code: 'en-US', title: 'English' }] }, + { + id: 'en-US-AvaMultilingualNeural', + name: 'Ava', + languages: [{ code: 'en-US', title: 'English' }], + labels: {}, + previewAudioUrl: null, + }, ]) }) + it('hides provider voices that are not enabled in the official catalog', async () => { + const llmRouter = createMockLlmRouter({ + listTtsVoices: vi.fn(async () => [ + { id: 'en-US-AvaMultilingualNeural', name: 'Ava' }, + ]) as any, + }) + const officialCatalogService = createMockOfficialCatalogService({ + listEnabledTtsVoices: vi.fn(async () => []), + }) + const app = createTestApp( + createMockFluxService(), + createMockConfigKV(), + undefined, + undefined, + undefined, + llmRouter, + createMockLlmTracing(), + createMockProductEventService(), + createMockVoicePackService(), + officialCatalogService, + ) + + const res = await app.fetch( + new Request('http://localhost/api/v1/audio/voices?model=microsoft/v1', { method: 'GET' }), + { user: testUser } as any, + ) + + expect(res.status).toBe(200) + const data = await res.json() as { voices: Array> } + expect(data.voices).toEqual([]) + expect(llmRouter.listTtsVoices).toHaveBeenCalledWith('microsoft/v1') + expect(officialCatalogService.syncTtsVoices).toHaveBeenCalledWith({ + routerModelId: 'microsoft/v1', + voices: [{ id: 'en-US-AvaMultilingualNeural', name: 'Ava', languages: undefined, labels: undefined, previewAudioUrl: null }], + }) + }) + it('returns an empty recommended map when the resolved model has no bucket', async () => { const llmRouter = createMockLlmRouter({ listTtsVoices: vi.fn(async () => []) as any, diff --git a/apps/server/src/routes/openai/v1/types.ts b/apps/server/src/routes/openai/v1/types.ts index b0fc4e5c1..cbf261595 100644 --- a/apps/server/src/routes/openai/v1/types.ts +++ b/apps/server/src/routes/openai/v1/types.ts @@ -5,6 +5,7 @@ import type { FluxMeter } from '../../../services/domain/billing/flux-meter' import type { FluxService } from '../../../services/domain/flux' import type { LlmRouterService } from '../../../services/domain/llm-router' import type { ChatGenerationTrace, TtsGenerationTrace } from '../../../services/domain/llm-tracing' +import type { OfficialCatalogService } from '../../../services/domain/official-catalog' import type { ProductEventService } from '../../../services/domain/product-events' import type { RequestLogService } from '../../../services/domain/request-log' import type { VoicePackService } from '../../../services/domain/voice-packs' @@ -25,6 +26,7 @@ export interface V1RouteDeps { ttsMeter: FluxMeter llmRouter: LlmRouterService voicePackService: VoicePackService + officialCatalogService: OfficialCatalogService genAi?: GenAiMetrics | null revenue?: RevenueMetrics | null rateLimitMetrics?: RateLimitMetrics | null diff --git a/apps/server/src/schemas/index.ts b/apps/server/src/schemas/index.ts index c9f44062e..4af95df02 100644 --- a/apps/server/src/schemas/index.ts +++ b/apps/server/src/schemas/index.ts @@ -4,6 +4,7 @@ export * from './chats' export * from './flux' export * from './flux-transaction' export * from './llm-request-log' +export * from './official-catalog' export * from './product-events' export * from './providers' export * from './stripe' diff --git a/apps/server/src/schemas/official-catalog.ts b/apps/server/src/schemas/official-catalog.ts new file mode 100644 index 000000000..911cea89c --- /dev/null +++ b/apps/server/src/schemas/official-catalog.ts @@ -0,0 +1,101 @@ +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' + +import { boolean, integer, jsonb, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core' + +import { nanoid } from '../utils/id' + +export type OfficialCatalogSurface = 'llm' | 'asr' +export type OfficialCatalogRoutePool = 'primary' | 'fallback' + +export interface OfficialTtsVoiceLanguage { + code: string + title?: string +} + +export type OfficialTtsVoiceLabels = Record + +export const officialProviderAliases = pgTable( + 'official_provider_aliases', + { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + surface: text('surface').notNull().$type(), + aliasId: text('alias_id').notNull(), + displayName: text('display_name').notNull(), + enabled: boolean('enabled').notNull().default(true), + displayOrder: integer('display_order').notNull().default(0), + fallbackEnabled: boolean('fallback_enabled').notNull().default(true), + loadBalancingEnabled: boolean('load_balancing_enabled').notNull().default(false), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + table => [ + uniqueIndex('official_provider_aliases_surface_alias_uidx').on(table.surface, table.aliasId), + ], +) + +export const officialProviderAliasRoutes = pgTable( + 'official_provider_alias_routes', + { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + aliasId: text('alias_id').notNull().references(() => officialProviderAliases.id, { onDelete: 'cascade' }), + routerModelId: text('router_model_id').notNull(), + pool: text('pool').notNull().$type().default('primary'), + enabled: boolean('enabled').notNull().default(true), + weight: integer('weight').notNull().default(1), + displayOrder: integer('display_order').notNull().default(0), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + table => [ + uniqueIndex('official_provider_alias_routes_alias_model_pool_uidx').on(table.aliasId, table.routerModelId, table.pool), + ], +) + +export const officialTtsModels = pgTable( + 'official_tts_models', + { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + routerModelId: text('router_model_id').notNull(), + provider: text('provider').notNull(), + displayName: text('display_name').notNull(), + enabled: boolean('enabled').notNull().default(true), + displayOrder: integer('display_order').notNull().default(0), + lastSyncedAt: timestamp('last_synced_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + table => [ + uniqueIndex('official_tts_models_router_model_uidx').on(table.routerModelId), + ], +) + +export const officialTtsVoices = pgTable( + 'official_tts_voices', + { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + ttsModelId: text('tts_model_id').notNull().references(() => officialTtsModels.id, { onDelete: 'cascade' }), + providerVoiceId: text('provider_voice_id').notNull(), + displayName: text('display_name').notNull(), + enabled: boolean('enabled').notNull().default(false), + displayOrder: integer('display_order').notNull().default(0), + languages: jsonb('languages').notNull().$type().default([]), + labels: jsonb('labels').notNull().$type().default({}), + previewAudioUrl: text('preview_audio_url'), + source: text('source').notNull().default('provider-sync'), + lastSyncedAt: timestamp('last_synced_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + table => [ + uniqueIndex('official_tts_voices_model_voice_uidx').on(table.ttsModelId, table.providerVoiceId), + ], +) + +export type OfficialProviderAlias = InferSelectModel +export type NewOfficialProviderAlias = InferInsertModel +export type OfficialProviderAliasRoute = InferSelectModel +export type NewOfficialProviderAliasRoute = InferInsertModel +export type OfficialTtsModel = InferSelectModel +export type NewOfficialTtsModel = InferInsertModel +export type OfficialTtsVoice = InferSelectModel +export type NewOfficialTtsVoice = InferInsertModel diff --git a/apps/server/src/services/domain/official-catalog/index.test.ts b/apps/server/src/services/domain/official-catalog/index.test.ts new file mode 100644 index 000000000..6b87bf02e --- /dev/null +++ b/apps/server/src/services/domain/official-catalog/index.test.ts @@ -0,0 +1,208 @@ +import type { Database } from '../../../libs/db' + +import { eq } from 'drizzle-orm' +import { beforeAll, beforeEach, describe, expect, it } from 'vitest' + +import { createOfficialCatalogService } from '.' +import { mockDB } from '../../../libs/mock-db' +import { officialProviderAliases, officialProviderAliasRoutes, officialTtsModels, officialTtsVoices } from '../../../schemas/official-catalog' +import { ApiError } from '../../../utils/error' + +import * as schema from '../../../schemas' + +describe('officialCatalogService', () => { + let db: Database + let service: ReturnType + + beforeAll(async () => { + db = await mockDB(schema) + service = createOfficialCatalogService(db) + }) + + beforeEach(async () => { + await db.delete(officialProviderAliasRoutes) + await db.delete(officialProviderAliases) + await db.delete(officialTtsVoices) + await db.delete(officialTtsModels) + }) + + it('syncs the default LLM auto alias and runtime model routes as enabled', async () => { + const aliases = await service.syncAliasesFromRouterConfig({ + surface: 'llm', + modelIds: ['chat-b', 'chat-a'], + }) + + expect(aliases).toHaveLength(1) + expect(aliases[0]).toMatchObject({ + surface: 'llm', + aliasId: 'auto', + displayName: 'Auto', + enabled: true, + fallbackEnabled: true, + loadBalancingEnabled: false, + }) + + const resolved = await service.resolveEnabledAlias('llm', 'auto') + expect(resolved.routes.map(route => route.routerModelId)).toEqual(['chat-b', 'chat-a']) + expect(resolved.routes.every(route => route.enabled)).toBe(true) + expect(resolved.routes.every(route => route.pool === 'primary')).toBe(true) + }) + + it('preserves alias and route curation across repeated syncs', async () => { + await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a'] }) + const [alias] = await db.select().from(officialProviderAliases) + const [route] = await db.select().from(officialProviderAliasRoutes) + + await db.update(officialProviderAliases) + .set({ enabled: false, displayName: 'Custom Auto', displayOrder: 5 }) + .where(eq(officialProviderAliases.id, alias.id)) + await db.update(officialProviderAliasRoutes) + .set({ enabled: false, displayOrder: 9 }) + .where(eq(officialProviderAliasRoutes.id, route.id)) + + await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a', 'chat-b'] }) + const aliases = await service.listAliases('llm') + const preservedRoute = aliases[0].routes.find(item => item.routerModelId === 'chat-a') + const newRoute = aliases[0].routes.find(item => item.routerModelId === 'chat-b') + + expect(aliases[0]).toMatchObject({ enabled: false, displayName: 'Custom Auto', displayOrder: 5 }) + expect(preservedRoute).toMatchObject({ enabled: false, displayOrder: 9 }) + expect(newRoute).toMatchObject({ enabled: true, displayOrder: 1 }) + }) + + it('syncs runtime TTS models as enabled but preserves admin display fields', async () => { + const first = await service.syncTtsModelsFromRouterConfig({ + models: { + 'alibaba/cosyvoice-v2': { provider: 'dashscope-cosyvoice' }, + }, + }) + await db.update(officialTtsModels) + .set({ enabled: false, displayName: 'Curated CosyVoice', displayOrder: 7 }) + .where(eq(officialTtsModels.id, first[0].id)) + + await service.syncTtsModelsFromRouterConfig({ + models: { + 'alibaba/cosyvoice-v2': { provider: 'dashscope-cosyvoice' }, + 'microsoft/v1': { provider: 'azure' }, + }, + }) + + const models = await service.listTtsModels() + expect(models.map(model => model.routerModelId)).toEqual(['alibaba/cosyvoice-v2', 'microsoft/v1']) + expect(models.find(model => model.routerModelId === 'alibaba/cosyvoice-v2')).toMatchObject({ + enabled: false, + displayName: 'Curated CosyVoice', + displayOrder: 7, + provider: 'dashscope-cosyvoice', + }) + expect(models.find(model => model.routerModelId === 'microsoft/v1')).toMatchObject({ + enabled: true, + displayName: 'microsoft/v1', + provider: 'azure', + }) + }) + + it('syncs provider voices as disabled by default and preserves curation on resync', async () => { + await service.syncTtsModelsFromRouterConfig({ + models: { 'microsoft/v1': { provider: 'azure' } }, + }) + + const first = await service.syncTtsVoices({ + routerModelId: 'microsoft/v1', + voices: [{ + id: 'en-US-AvaMultilingualNeural', + name: 'Ava', + languages: [{ code: 'en-US', title: 'English' }], + labels: { gender: 'female' }, + previewAudioUrl: 'https://example.com/ava.mp3', + }], + }) + expect(first[0]).toMatchObject({ + providerVoiceId: 'en-US-AvaMultilingualNeural', + displayName: 'Ava', + enabled: false, + previewAudioUrl: 'https://example.com/ava.mp3', + }) + + await db.update(officialTtsVoices) + .set({ + enabled: true, + displayName: 'Curated Ava', + displayOrder: 3, + previewAudioUrl: 'https://example.com/manual.mp3', + }) + .where(eq(officialTtsVoices.id, first[0].id)) + + await service.syncTtsVoices({ + routerModelId: 'microsoft/v1', + voices: [{ + id: 'en-US-AvaMultilingualNeural', + name: 'Ava from provider', + languages: [{ code: 'en-US', title: 'English US' }], + labels: { gender: 'Female' }, + previewAudioUrl: 'https://example.com/provider-new.mp3', + }], + }) + + const voices = await service.listTtsVoices('microsoft/v1') + expect(voices[0]).toMatchObject({ + enabled: true, + displayName: 'Curated Ava', + displayOrder: 3, + previewAudioUrl: 'https://example.com/manual.mp3', + labels: { gender: 'Female' }, + languages: [{ code: 'en-US', title: 'English US' }], + }) + }) + + it('lists and gates only enabled TTS models and voices', async () => { + const [model] = await service.syncTtsModelsFromRouterConfig({ + models: { 'microsoft/v1': { provider: 'azure' } }, + }) + const [voice] = await service.syncTtsVoices({ + routerModelId: 'microsoft/v1', + voices: [{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }], + }) + + expect(await service.listEnabledTtsModels()).toHaveLength(1) + expect(await service.listEnabledTtsVoices('microsoft/v1')).toEqual([]) + + await db.update(officialTtsVoices) + .set({ enabled: true }) + .where(eq(officialTtsVoices.id, voice.id)) + expect((await service.listEnabledTtsVoices('microsoft/v1')).map(item => item.providerVoiceId)).toEqual(['en-US-AvaMultilingualNeural']) + + await db.update(officialTtsModels) + .set({ enabled: false }) + .where(eq(officialTtsModels.id, model.id)) + + await expect(service.assertTtsModelEnabled('microsoft/v1')).rejects.toMatchObject({ + errorCode: 'OFFICIAL_MODEL_DISABLED', + }) + await expect(service.assertTtsVoiceEnabled('microsoft/v1', 'en-US-AvaMultilingualNeural')).rejects.toMatchObject({ + errorCode: 'OFFICIAL_MODEL_DISABLED', + }) + }) + + it('throws structured errors for missing or disabled aliases and voices', async () => { + await expect(service.resolveEnabledAlias('llm', 'auto')).rejects.toMatchObject({ + errorCode: 'OFFICIAL_ALIAS_NOT_FOUND', + }) + + await service.syncAliasesFromRouterConfig({ surface: 'llm', modelIds: ['chat-a'] }) + const [alias] = await db.select().from(officialProviderAliases) + await db.update(officialProviderAliases) + .set({ enabled: false }) + .where(eq(officialProviderAliases.id, alias.id)) + + await expect(service.resolveEnabledAlias('llm', 'auto')).rejects.toMatchObject({ + errorCode: 'OFFICIAL_ALIAS_DISABLED', + }) + + await service.syncTtsModelsFromRouterConfig({ models: { 'microsoft/v1': { provider: 'azure' } } }) + await expect(service.assertTtsVoiceEnabled('microsoft/v1', 'missing')).rejects.toBeInstanceOf(ApiError) + await expect(service.assertTtsVoiceEnabled('microsoft/v1', 'missing')).rejects.toMatchObject({ + errorCode: 'OFFICIAL_VOICE_NOT_FOUND', + }) + }) +}) diff --git a/apps/server/src/services/domain/official-catalog/index.ts b/apps/server/src/services/domain/official-catalog/index.ts new file mode 100644 index 000000000..7c05eba38 --- /dev/null +++ b/apps/server/src/services/domain/official-catalog/index.ts @@ -0,0 +1,416 @@ +import type { Database } from '../../../libs/db' +import type { + OfficialCatalogRoutePool, + OfficialCatalogSurface, + OfficialProviderAlias, + OfficialProviderAliasRoute, + OfficialTtsModel, + OfficialTtsVoice, + OfficialTtsVoiceLabels, + OfficialTtsVoiceLanguage, +} from '../../../schemas/official-catalog' + +import { and, asc, eq, inArray } from 'drizzle-orm' + +import { + officialProviderAliases, + officialProviderAliasRoutes, + officialTtsModels, + officialTtsVoices, +} from '../../../schemas/official-catalog' +import { createBadRequestError } from '../../../utils/error' + +const DEFAULT_ALIAS_ID = 'auto' + +export interface OfficialTtsModelSyncInput { + provider: string +} + +export interface OfficialTtsVoiceSyncInput { + id: string + name?: string + languages?: OfficialTtsVoiceLanguage[] + labels?: OfficialTtsVoiceLabels + previewAudioUrl?: string | null +} + +export interface OfficialProviderAliasWithRoutes extends OfficialProviderAlias { + routes: OfficialProviderAliasRoute[] +} + +export interface OfficialProviderAliasUpdateInput { + displayName?: string + enabled?: boolean + displayOrder?: number + fallbackEnabled?: boolean + loadBalancingEnabled?: boolean +} + +export interface OfficialProviderAliasRouteUpdateInput { + enabled?: boolean + pool?: OfficialCatalogRoutePool + weight?: number + displayOrder?: number +} + +export interface OfficialTtsModelUpdateInput { + displayName?: string + enabled?: boolean + displayOrder?: number +} + +export interface OfficialTtsVoiceUpdateInput { + displayName?: string + enabled?: boolean + displayOrder?: number + languages?: OfficialTtsVoiceLanguage[] + labels?: OfficialTtsVoiceLabels + previewAudioUrl?: string | null +} + +function defaultAliasDisplayName(surface: OfficialCatalogSurface, aliasId: string): string { + if (aliasId !== DEFAULT_ALIAS_ID) + return aliasId + return surface === 'llm' ? 'Auto' : 'Auto Transcription' +} + +function nextOrder(rows: Array<{ displayOrder: number }>): number { + if (rows.length === 0) + return 0 + return Math.max(...rows.map(row => row.displayOrder)) + 1 +} + +function catalogError(message: string, errorCode: string, details?: unknown) { + return createBadRequestError(message, errorCode, details) +} + +/** + * Owns AIRI's official product catalog. + * + * The router config still owns real provider URLs, keys, and fallback + * mechanics. This service owns what users can see and what requests may use. + * Public list endpoints and gateway request gates should both call this + * service so UI hiding and handwritten request validation cannot drift. + */ +export function createOfficialCatalogService(db: Database) { + async function findAlias(surface: OfficialCatalogSurface, aliasId: string) { + return await db.query.officialProviderAliases.findFirst({ + where: and( + eq(officialProviderAliases.surface, surface), + eq(officialProviderAliases.aliasId, aliasId), + ), + }) + } + + async function ensureAlias(surface: OfficialCatalogSurface, aliasId: string) { + const existing = await findAlias(surface, aliasId) + if (existing) + return existing + + const existingAliases = await db.query.officialProviderAliases.findMany({ + where: eq(officialProviderAliases.surface, surface), + }) + const [created] = await db.insert(officialProviderAliases).values({ + surface, + aliasId, + displayName: defaultAliasDisplayName(surface, aliasId), + enabled: true, + displayOrder: nextOrder(existingAliases), + fallbackEnabled: true, + loadBalancingEnabled: false, + }).returning() + return created + } + + async function syncAliasRoute(input: { + aliasRowId: string + routerModelId: string + pool: OfficialCatalogRoutePool + order: number + }) { + const existing = await db.query.officialProviderAliasRoutes.findFirst({ + where: and( + eq(officialProviderAliasRoutes.aliasId, input.aliasRowId), + eq(officialProviderAliasRoutes.routerModelId, input.routerModelId), + eq(officialProviderAliasRoutes.pool, input.pool), + ), + }) + + if (existing) { + const [updated] = await db.update(officialProviderAliasRoutes) + .set({ updatedAt: new Date() }) + .where(eq(officialProviderAliasRoutes.id, existing.id)) + .returning() + return updated + } + + const [created] = await db.insert(officialProviderAliasRoutes).values({ + aliasId: input.aliasRowId, + routerModelId: input.routerModelId, + pool: input.pool, + enabled: true, + weight: 1, + displayOrder: input.order, + }).returning() + return created + } + + return { + async syncAliasesFromRouterConfig(input: { + surface: OfficialCatalogSurface + modelIds: string[] + }) { + const alias = await ensureAlias(input.surface, DEFAULT_ALIAS_ID) + const uniqueModelIds = Array.from(new Set(input.modelIds)) + for (const [index, routerModelId] of uniqueModelIds.entries()) { + await syncAliasRoute({ + aliasRowId: alias.id, + routerModelId, + pool: 'primary', + order: index, + }) + } + + return await db.query.officialProviderAliases.findMany({ + where: eq(officialProviderAliases.surface, input.surface), + orderBy: [asc(officialProviderAliases.displayOrder), asc(officialProviderAliases.aliasId)], + }) + }, + + async listAliases(surface?: OfficialCatalogSurface): Promise { + const aliases = await db.query.officialProviderAliases.findMany({ + where: surface ? eq(officialProviderAliases.surface, surface) : undefined, + orderBy: [asc(officialProviderAliases.displayOrder), asc(officialProviderAliases.aliasId)], + }) + if (aliases.length === 0) + return [] + + const routes = await db.query.officialProviderAliasRoutes.findMany({ + where: inArray(officialProviderAliasRoutes.aliasId, aliases.map(alias => alias.id)), + orderBy: [asc(officialProviderAliasRoutes.displayOrder), asc(officialProviderAliasRoutes.routerModelId)], + }) + return aliases.map(alias => ({ + ...alias, + routes: routes.filter(route => route.aliasId === alias.id), + })) + }, + + async updateAlias(id: string, input: OfficialProviderAliasUpdateInput): Promise { + const [updated] = await db.update(officialProviderAliases) + .set({ ...input, updatedAt: new Date() }) + .where(eq(officialProviderAliases.id, id)) + .returning() + return updated ?? null + }, + + async updateAliasRoute(id: string, input: OfficialProviderAliasRouteUpdateInput): Promise { + const [updated] = await db.update(officialProviderAliasRoutes) + .set({ ...input, updatedAt: new Date() }) + .where(eq(officialProviderAliasRoutes.id, id)) + .returning() + return updated ?? null + }, + + async resolveEnabledAlias(surface: OfficialCatalogSurface, aliasId: string): Promise { + const alias = await findAlias(surface, aliasId) + if (!alias) { + throw catalogError('Official provider alias is not configured', 'OFFICIAL_ALIAS_NOT_FOUND', { surface, aliasId }) + } + if (!alias.enabled) { + throw catalogError('Official provider alias is disabled', 'OFFICIAL_ALIAS_DISABLED', { surface, aliasId }) + } + + const routes = await db.query.officialProviderAliasRoutes.findMany({ + where: and( + eq(officialProviderAliasRoutes.aliasId, alias.id), + eq(officialProviderAliasRoutes.enabled, true), + ), + orderBy: [asc(officialProviderAliasRoutes.displayOrder), asc(officialProviderAliasRoutes.routerModelId)], + }) + if (routes.length === 0) { + throw catalogError('Official provider alias has no enabled route', 'OFFICIAL_ALIAS_ROUTE_NOT_FOUND', { surface, aliasId }) + } + + return { ...alias, routes } + }, + + async syncTtsModelsFromRouterConfig(input: { + models: Record + }) { + const existingModels = await db.query.officialTtsModels.findMany() + const existingByRouterModel = new Map(existingModels.map(model => [model.routerModelId, model])) + const synced: OfficialTtsModel[] = [] + const now = new Date() + + for (const [routerModelId, model] of Object.entries(input.models).sort(([a], [b]) => a.localeCompare(b))) { + const existing = existingByRouterModel.get(routerModelId) + if (existing) { + const [updated] = await db.update(officialTtsModels) + .set({ + provider: model.provider, + lastSyncedAt: now, + updatedAt: now, + }) + .where(eq(officialTtsModels.id, existing.id)) + .returning() + synced.push(updated) + continue + } + + const [created] = await db.insert(officialTtsModels).values({ + routerModelId, + provider: model.provider, + displayName: routerModelId, + enabled: true, + displayOrder: nextOrder([...existingModels, ...synced]), + lastSyncedAt: now, + }).returning() + synced.push(created) + } + + return synced + }, + + async listTtsModels(): Promise { + return await db.query.officialTtsModels.findMany({ + orderBy: [asc(officialTtsModels.displayOrder), asc(officialTtsModels.routerModelId)], + }) + }, + + async updateTtsModel(id: string, input: OfficialTtsModelUpdateInput): Promise { + const [updated] = await db.update(officialTtsModels) + .set({ ...input, updatedAt: new Date() }) + .where(eq(officialTtsModels.id, id)) + .returning() + return updated ?? null + }, + + async listEnabledTtsModels(): Promise { + return await db.query.officialTtsModels.findMany({ + where: eq(officialTtsModels.enabled, true), + orderBy: [asc(officialTtsModels.displayOrder), asc(officialTtsModels.routerModelId)], + }) + }, + + async assertTtsModelEnabled(routerModelId: string): Promise { + const model = await db.query.officialTtsModels.findFirst({ + where: eq(officialTtsModels.routerModelId, routerModelId), + }) + if (!model) { + throw catalogError('Official TTS model is not configured', 'OFFICIAL_MODEL_NOT_FOUND', { model: routerModelId }) + } + if (!model.enabled) { + throw catalogError('Official TTS model is disabled', 'OFFICIAL_MODEL_DISABLED', { model: routerModelId }) + } + return model + }, + + async syncTtsVoices(input: { + routerModelId: string + voices: OfficialTtsVoiceSyncInput[] + }) { + const model = await db.query.officialTtsModels.findFirst({ + where: eq(officialTtsModels.routerModelId, input.routerModelId), + }) + if (!model) { + throw catalogError('Official TTS model is not configured', 'OFFICIAL_MODEL_NOT_FOUND', { model: input.routerModelId }) + } + const existingVoices = await db.query.officialTtsVoices.findMany({ + where: eq(officialTtsVoices.ttsModelId, model.id), + }) + const existingByVoiceId = new Map(existingVoices.map(voice => [voice.providerVoiceId, voice])) + const synced: OfficialTtsVoice[] = [] + const now = new Date() + + for (const voice of input.voices) { + const existing = existingByVoiceId.get(voice.id) + if (existing) { + const [updated] = await db.update(officialTtsVoices) + .set({ + languages: voice.languages ?? existing.languages, + labels: voice.labels ?? existing.labels, + lastSyncedAt: now, + updatedAt: now, + }) + .where(eq(officialTtsVoices.id, existing.id)) + .returning() + synced.push(updated) + continue + } + + const [created] = await db.insert(officialTtsVoices).values({ + ttsModelId: model.id, + providerVoiceId: voice.id, + displayName: voice.name ?? voice.id, + enabled: false, + displayOrder: nextOrder([...existingVoices, ...synced]), + languages: voice.languages ?? [], + labels: voice.labels ?? {}, + previewAudioUrl: voice.previewAudioUrl ?? null, + source: 'provider-sync', + lastSyncedAt: now, + }).returning() + synced.push(created) + } + + return synced + }, + + async listTtsVoices(routerModelId: string): Promise { + const model = await db.query.officialTtsModels.findFirst({ + where: eq(officialTtsModels.routerModelId, routerModelId), + }) + if (!model) + return [] + + return await db.query.officialTtsVoices.findMany({ + where: eq(officialTtsVoices.ttsModelId, model.id), + orderBy: [asc(officialTtsVoices.displayOrder), asc(officialTtsVoices.providerVoiceId)], + }) + }, + + async updateTtsVoice(id: string, input: OfficialTtsVoiceUpdateInput): Promise { + const [updated] = await db.update(officialTtsVoices) + .set({ ...input, updatedAt: new Date() }) + .where(eq(officialTtsVoices.id, id)) + .returning() + return updated ?? null + }, + + async listEnabledTtsVoices(routerModelId: string): Promise { + const model = await this.assertTtsModelEnabled(routerModelId) + return await db.query.officialTtsVoices.findMany({ + where: and( + eq(officialTtsVoices.ttsModelId, model.id), + eq(officialTtsVoices.enabled, true), + ), + orderBy: [asc(officialTtsVoices.displayOrder), asc(officialTtsVoices.providerVoiceId)], + }) + }, + + async assertTtsVoiceEnabled(routerModelId: string, providerVoiceId: string): Promise { + const model = await this.assertTtsModelEnabled(routerModelId) + const voice = await db.query.officialTtsVoices.findFirst({ + where: and( + eq(officialTtsVoices.ttsModelId, model.id), + eq(officialTtsVoices.providerVoiceId, providerVoiceId), + ), + }) + if (!voice) { + throw catalogError('Official TTS voice is not configured for this model', 'OFFICIAL_VOICE_NOT_FOUND', { + model: routerModelId, + voice: providerVoiceId, + }) + } + if (!voice.enabled) { + throw catalogError('Official TTS voice is disabled', 'OFFICIAL_VOICE_DISABLED', { + model: routerModelId, + voice: providerVoiceId, + }) + } + return voice + }, + } +} + +export type OfficialCatalogService = ReturnType diff --git a/apps/server/src/services/domain/official-catalog/provider-voices.ts b/apps/server/src/services/domain/official-catalog/provider-voices.ts new file mode 100644 index 000000000..f8250866c --- /dev/null +++ b/apps/server/src/services/domain/official-catalog/provider-voices.ts @@ -0,0 +1,65 @@ +import type { OfficialTtsVoice, OfficialTtsVoiceLabels, OfficialTtsVoiceLanguage } from '../../../schemas/official-catalog' + +function asRecord(value: unknown): Record | undefined { + if (typeof value !== 'object' || value == null || Array.isArray(value)) + return undefined + return value as Record +} + +function asOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function asLanguageList(value: unknown): OfficialTtsVoiceLanguage[] | undefined { + if (!Array.isArray(value)) + return undefined + + const languages = value.flatMap((item) => { + const record = asRecord(item) + const code = asOptionalString(record?.code) + if (!code) + return [] + const title = asOptionalString(record?.title) + return [{ code, ...(title ? { title } : {}) }] + }) + return languages.length > 0 ? languages : undefined +} + +function asLabels(value: unknown): OfficialTtsVoiceLabels | undefined { + const record = asRecord(value) + return record ? { ...record } : undefined +} + +/** + * Normalizes a provider-specific voice object into the official catalog sync shape. + * + * Before: + * - `{ id: "en-US-AvaMultilingualNeural", name: "Ava", previewUrl: "https://..." }` + * + * After: + * - `{ id: "en-US-AvaMultilingualNeural", name: "Ava", previewAudioUrl: "https://..." }` + */ +export function normalizeProviderVoiceForCatalog(value: unknown) { + const record = asRecord(value) + const id = asOptionalString(record?.id) + if (!id) + return null + + return { + id, + name: asOptionalString(record?.name), + languages: asLanguageList(record?.languages), + labels: asLabels(record?.labels), + previewAudioUrl: asOptionalString(record?.previewAudioUrl) ?? asOptionalString(record?.previewUrl) ?? null, + } +} + +export function catalogVoiceResponse(voice: OfficialTtsVoice) { + return { + id: voice.providerVoiceId, + name: voice.displayName, + languages: voice.languages, + labels: voice.labels, + previewAudioUrl: voice.previewAudioUrl, + } +} diff --git a/apps/server/src/services/domain/openai-speech/index.ts b/apps/server/src/services/domain/openai-speech/index.ts index 7403d1f9e..5322c9e4f 100644 --- a/apps/server/src/services/domain/openai-speech/index.ts +++ b/apps/server/src/services/domain/openai-speech/index.ts @@ -4,6 +4,7 @@ import type { FluxMeter } from '../billing/flux-meter' import type { FluxService } from '../flux' import type { LlmRouterService } from '../llm-router' import type { startTtsGeneration, TtsGenerationTrace } from '../llm-tracing' +import type { OfficialCatalogService } from '../official-catalog' import type { ProductEventService } from '../product-events' import type { RequestLogService } from '../request-log' import type { VoicePackService } from '../voice-packs' @@ -49,6 +50,7 @@ export interface OpenAiSpeechServiceDeps { ttsMeter: FluxMeter llmRouter: LlmRouterService voicePackService: VoicePackService + officialCatalogService: OfficialCatalogService productEventService: ProductEventService genAi?: GenAiMetrics | null llmTracing: { @@ -106,6 +108,10 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { if (requestModel === 'auto') requestModel = await deps.configKV.getOrThrow('DEFAULT_TTS_MODEL') const routedVoice = voicePackRequest.voice ?? requestVoice + await deps.officialCatalogService.assertTtsModelEnabled(requestModel) + if (!voicePackRequest.voicePackId && routedVoice) + await deps.officialCatalogService.assertTtsVoiceEnabled(requestModel, routedVoice) + const voiceMetadata = ttsVoiceMetadata({ voice: requestVoice, voicePackId: voicePackRequest.voicePackId, diff --git a/apps/ui-admin/src/App.vue b/apps/ui-admin/src/App.vue index 2d9707fe2..d0c93f51d 100644 --- a/apps/ui-admin/src/App.vue +++ b/apps/ui-admin/src/App.vue @@ -23,6 +23,8 @@ const navItems = [ { to: '/users', icon: 'i-lucide-users', label: 'Users' }, { to: '/flux', icon: 'i-lucide-coins', label: 'Flux' }, { to: '/llm-router', icon: 'i-lucide-route', label: 'LLM Router' }, + { to: '/providers', icon: 'i-lucide-network', label: 'Providers' }, + { to: '/tts', icon: 'i-lucide-audio-lines', label: 'TTS' }, { to: '/voice-packs', icon: 'i-lucide-volume-2', label: 'Voice Packs' }, ] diff --git a/apps/ui-admin/src/main.ts b/apps/ui-admin/src/main.ts index 673b87ddf..ddb4b18fa 100644 --- a/apps/ui-admin/src/main.ts +++ b/apps/ui-admin/src/main.ts @@ -9,6 +9,8 @@ import App from './App.vue' import FluxPage from './pages/FluxPage.vue' import LlmRouterPage from './pages/LlmRouterPage.vue' import OverviewPage from './pages/OverviewPage.vue' +import ProviderCatalogPage from './pages/ProviderCatalogPage.vue' +import TtsCatalogPage from './pages/TtsCatalogPage.vue' import UsersPage from './pages/UsersPage.vue' import VoicePackFormPage from './pages/VoicePackFormPage.vue' import VoicePacksPage from './pages/VoicePacksPage.vue' @@ -26,6 +28,8 @@ const router = createRouter({ { path: '/users', component: UsersPage }, { path: '/flux', component: FluxPage }, { path: '/llm-router', component: LlmRouterPage }, + { path: '/providers', component: ProviderCatalogPage }, + { path: '/tts', component: TtsCatalogPage }, { path: '/voice-packs', component: VoicePacksPage }, { path: '/voice-packs/new', name: 'voice-pack-new', component: VoicePackFormPage }, { path: '/voice-packs/:id/edit', name: 'voice-pack-edit', component: VoicePackFormPage }, diff --git a/apps/ui-admin/src/modules/api.ts b/apps/ui-admin/src/modules/api.ts index 5ff112364..e796242de 100644 --- a/apps/ui-admin/src/modules/api.ts +++ b/apps/ui-admin/src/modules/api.ts @@ -239,6 +239,63 @@ export interface SpeechTestPayload { } } +export type OfficialCatalogSurface = 'llm' | 'asr' +export type OfficialCatalogRoutePool = 'primary' | 'fallback' + +export interface OfficialProviderAliasRoute { + id: string + aliasId: string + routerModelId: string + pool: OfficialCatalogRoutePool + enabled: boolean + weight: number + displayOrder: number + createdAt: string + updatedAt: string +} + +export interface OfficialProviderAlias { + id: string + surface: OfficialCatalogSurface + aliasId: string + displayName: string + enabled: boolean + displayOrder: number + fallbackEnabled: boolean + loadBalancingEnabled: boolean + routes: OfficialProviderAliasRoute[] + createdAt: string + updatedAt: string +} + +export interface OfficialTtsModel { + id: string + routerModelId: string + provider: string + displayName: string + enabled: boolean + displayOrder: number + lastSyncedAt: string | null + createdAt: string + updatedAt: string +} + +export interface OfficialTtsVoice { + id: string + ttsModelId: string + providerVoiceId: string + displayName: string + enabled: boolean + displayOrder: number + languages: Array<{ code: string, title?: string }> + labels: Record + previewAudioUrl: string | null + source: 'provider-sync' | 'manual' + lastSyncedAt: string | null + createdAt: string + updatedAt: string +} + export class AdminApiError extends Error { constructor( message: string, @@ -442,4 +499,47 @@ export const adminApi = { adminFetch(`/voice-packs/${encodeURIComponent(id)}/disable`, { method: 'POST', }), + officialAliases: (surface?: OfficialCatalogSurface) => { + const suffix = surface ? `?surface=${encodeURIComponent(surface)}` : '' + return adminFetch(`/official-catalog/aliases${suffix}`) + }, + syncOfficialAliases: (surface: OfficialCatalogSurface) => + adminFetch<{ aliases: OfficialProviderAlias[] }>('/official-catalog/aliases/sync', { + method: 'POST', + body: JSON.stringify({ surface }), + }), + updateOfficialAlias: (id: string, body: Partial>) => + adminFetch(`/official-catalog/aliases/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), + updateOfficialAliasRoute: (id: string, body: Partial>) => + adminFetch(`/official-catalog/alias-routes/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), + officialTtsModels: () => adminFetch('/official-catalog/tts/models'), + syncOfficialTtsModels: () => + adminFetch<{ models: OfficialTtsModel[] }>('/official-catalog/tts/models/sync', { + method: 'POST', + }), + updateOfficialTtsModel: (id: string, body: Partial>) => + adminFetch(`/official-catalog/tts/models/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), + officialTtsVoices: (model: string) => { + const query = new URLSearchParams({ model }) + return adminFetch(`/official-catalog/tts/voices?${query.toString()}`) + }, + syncOfficialTtsVoices: (routerModelId: string) => + adminFetch<{ voices: OfficialTtsVoice[], syncedCount: number }>('/official-catalog/tts/voices/sync', { + method: 'POST', + body: JSON.stringify({ routerModelId }), + }), + updateOfficialTtsVoice: (id: string, body: Partial>) => + adminFetch(`/official-catalog/tts/voices/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), } diff --git a/apps/ui-admin/src/pages/ProviderCatalogPage.vue b/apps/ui-admin/src/pages/ProviderCatalogPage.vue new file mode 100644 index 000000000..548a2ff51 --- /dev/null +++ b/apps/ui-admin/src/pages/ProviderCatalogPage.vue @@ -0,0 +1,219 @@ + + + diff --git a/apps/ui-admin/src/pages/TtsCatalogPage.vue b/apps/ui-admin/src/pages/TtsCatalogPage.vue new file mode 100644 index 000000000..2498147a7 --- /dev/null +++ b/apps/ui-admin/src/pages/TtsCatalogPage.vue @@ -0,0 +1,281 @@ + + + diff --git a/docs/superpowers/plans/2026-07-01-official-provider-catalog.md b/docs/superpowers/plans/2026-07-01-official-provider-catalog.md new file mode 100644 index 000000000..6fec78ce4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-official-provider-catalog.md @@ -0,0 +1,168 @@ +# Official Provider Catalog Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an official catalog layer that controls AIRI's official LLM aliases, TTS models, TTS voices, and ASR aliases for both public listing and gateway request authorization. + +**Architecture:** Add database-backed catalog tables and a focused `official-catalog` domain service. Public routes and gateway operations read the same service so UI visibility and handwritten request authorization cannot drift. Admin UI writes catalog state; `LLM_ROUTER_CONFIG` remains the source for real provider/key routing. + +**Tech Stack:** TypeScript, Hono, Drizzle ORM, Valibot, Vue 3 `