From e6fa39ed4ba0c1708d83f62b42eb7cb7727e9f0e Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 5 Jun 2026 22:39:30 +0800 Subject: [PATCH] feat(server): voice pack and tts routing (#1905) Track per-app TTS concurrency in Redis, route capped upstreams by available pool capacity, and surface pool saturation metrics. Document the Voice Pack plan so the remaining backend and card-binding work has an explicit implementation map. --- .../drizzle/0015_concerned_piledriver.sql | 14 + apps/server/drizzle/meta/0015_snapshot.json | 3026 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 9 +- apps/server/src/app.test.ts | 1 + apps/server/src/app.ts | 41 +- apps/server/src/otel/gauges/tts-pool.test.ts | 118 + apps/server/src/otel/gauges/tts-pool.ts | 82 + apps/server/src/otel/index.ts | 33 + .../src/routes/admin/voice-packs/index.ts | 116 + .../routes/admin/voice-packs/route.test.ts | 196 ++ .../v1/operations/speech-generation/index.ts | 1 + .../server/src/routes/openai/v1/route.test.ts | 215 +- apps/server/src/routes/openai/v1/types.ts | 2 + apps/server/src/routes/voice-packs/index.ts | 22 + .../src/routes/voice-packs/route.test.ts | 56 + apps/server/src/schemas/index.ts | 1 + apps/server/src/schemas/voice-packs.ts | 30 + .../server/src/services/adapters/config-kv.ts | 7 + .../server/src/services/adapters/tts/azure.ts | 73 +- .../adapters/tts/dashscope-cosyvoice.test.ts | 27 + .../adapters/tts/dashscope-cosyvoice.ts | 6 + .../src/services/adapters/tts/index.test.ts | 40 +- .../src/services/adapters/tts/volcengine.ts | 8 +- .../domain/llm-router/concurrency-ledger.ts | 149 + .../src/services/domain/llm-router/index.ts | 3 + .../src/services/domain/llm-router/router.ts | 192 +- .../tests/concurrency-ledger.test.ts | 135 + .../domain/llm-router/tests/router.test.ts | 283 +- .../services/domain/openai-speech/index.ts | 115 +- .../src/services/domain/product-events.ts | 5 +- .../services/domain/voice-packs/index.test.ts | 126 + .../src/services/domain/voice-packs/index.ts | 128 + apps/server/src/utils/observability.ts | 12 + apps/server/src/utils/redis-keys.ts | 28 + apps/ui-admin/README.md | 25 + apps/ui-admin/src/App.vue | 1 + apps/ui-admin/src/main.ts | 2 + apps/ui-admin/src/modules/api.ts | 46 + apps/ui-admin/src/pages/VoicePacksPage.vue | 371 ++ .../2026-05-30-voice-pack-requirements.md | 113 + ...30-001-feat-voice-pack-tts-pool-lb-plan.md | 308 ++ packages/i18n/src/locales/en/settings.yaml | 6 + .../src/pages/settings/modules/speech.vue | 122 +- .../stage-ui/src/components/scenes/Stage.vue | 44 +- .../providers/official/index.test.ts | 42 + .../providers/providers/official/index.ts | 7 +- packages/stage-ui/src/stores/index.ts | 1 + .../src/stores/modules/airi-card.test.ts | 66 + .../stage-ui/src/stores/modules/airi-card.ts | 76 + .../src/stores/modules/speech.test.ts | 157 + .../stage-ui/src/stores/modules/speech.ts | 181 +- packages/stage-ui/src/stores/voice-packs.ts | 60 + pnpm-lock.yaml | 260 +- 53 files changed, 7107 insertions(+), 81 deletions(-) create mode 100644 apps/server/drizzle/0015_concerned_piledriver.sql create mode 100644 apps/server/drizzle/meta/0015_snapshot.json create mode 100644 apps/server/src/otel/gauges/tts-pool.test.ts create mode 100644 apps/server/src/otel/gauges/tts-pool.ts create mode 100644 apps/server/src/routes/admin/voice-packs/index.ts create mode 100644 apps/server/src/routes/admin/voice-packs/route.test.ts create mode 100644 apps/server/src/routes/voice-packs/index.ts create mode 100644 apps/server/src/routes/voice-packs/route.test.ts create mode 100644 apps/server/src/schemas/voice-packs.ts create mode 100644 apps/server/src/services/domain/llm-router/concurrency-ledger.ts create mode 100644 apps/server/src/services/domain/llm-router/tests/concurrency-ledger.test.ts create mode 100644 apps/server/src/services/domain/voice-packs/index.test.ts create mode 100644 apps/server/src/services/domain/voice-packs/index.ts create mode 100644 apps/ui-admin/README.md create mode 100644 apps/ui-admin/src/pages/VoicePacksPage.vue create mode 100644 docs/brainstorms/2026-05-30-voice-pack-requirements.md create mode 100644 docs/plans/2026-05-30-001-feat-voice-pack-tts-pool-lb-plan.md create mode 100644 packages/stage-ui/src/libs/providers/providers/official/index.test.ts create mode 100644 packages/stage-ui/src/stores/voice-packs.ts diff --git a/apps/server/drizzle/0015_concerned_piledriver.sql b/apps/server/drizzle/0015_concerned_piledriver.sql new file mode 100644 index 000000000..a235b2072 --- /dev/null +++ b/apps/server/drizzle/0015_concerned_piledriver.sql @@ -0,0 +1,14 @@ +CREATE TABLE "voice_packs" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "description" text, + "provider" text NOT NULL, + "model" text NOT NULL, + "voice_id" text NOT NULL, + "tts_model_id" text NOT NULL, + "params" jsonb DEFAULT '{}'::jsonb NOT NULL, + "cost_multiplier" real DEFAULT 1 NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); diff --git a/apps/server/drizzle/meta/0015_snapshot.json b/apps/server/drizzle/meta/0015_snapshot.json new file mode 100644 index 000000000..e3f72c0da --- /dev/null +++ b/apps/server/drizzle/meta/0015_snapshot.json @@ -0,0 +1,3026 @@ +{ + "id": "d44a483a-4be0-4120-80e2-2f88a978479d", + "prevId": "62e2578e-592c-40a5-85e3-b527aeee5eb8", + "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.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 + }, + "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": {} + } +} diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index e8cd1b8df..f12e37de9 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1780498188307, "tag": "0014_vengeful_blonde_phantom", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1780498188308, + "tag": "0015_concerned_piledriver", + "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 790bfb032..b755ea119 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -51,6 +51,7 @@ function createTestDeps() { adminUsersService: {} as any, ttsMeter: {} as any, requestLogService: {} as any, + voicePackService: {} 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 c7d3d3382..bd7653ad8 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -20,6 +20,7 @@ import type { ProviderService } from './services/domain/providers' import type { RequestLogService } from './services/domain/request-log' import type { StripeService } from './services/domain/stripe' import type { UserDeletionService } from './services/domain/user-deletion' +import type { VoicePackService } from './services/domain/voice-packs' import type { HonoEnv } from './types/hono' import type { EnvelopeCrypto } from './utils/envelope-crypto' @@ -50,11 +51,13 @@ import { registerActiveSessionsGauge } from './otel/gauges/active-sessions' import { registerDistinctActiveUsersGauge } from './otel/gauges/distinct-active-users' import { registerRollingActiveUsersGauge } from './otel/gauges/rolling-active-users' import { registerTotalUsersGauge } from './otel/gauges/total-users' +import { registerTtsPoolGauge } from './otel/gauges/tts-pool' 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 { createAdminUsersRoutes } from './routes/admin/users' +import { createAdminVoicePackRoutes } from './routes/admin/voice-packs' import { createAudioSpeechWsHandlers } from './routes/audio-speech-ws' import { createAuthRoutes } from './routes/auth' import { createCharacterRoutes } from './routes/characters' @@ -64,6 +67,7 @@ import { createFluxRoutes } from './routes/flux' import { createV1Routes } from './routes/openai/v1' import { createProviderRoutes } from './routes/providers' import { createStripeRoutes } from './routes/stripe' +import { createVoicePackRoutes } from './routes/voice-packs' import { createConfigKVService } from './services/adapters/config-kv' import { createEmailService } from './services/adapters/email' import { createAdminFluxGrantsService } from './services/domain/admin/flux-grants' @@ -75,12 +79,13 @@ import { createCharacterService } from './services/domain/characters' import { createChatService } from './services/domain/chats' import { createFluxService } from './services/domain/flux' import { createFluxTransactionService } from './services/domain/flux-transaction' -import { createConfigSyncSubscriber, createLlmRouterService } from './services/domain/llm-router' +import { createConcurrencyLedger, createConfigSyncSubscriber, createLlmRouterService } from './services/domain/llm-router' import { createProductEventService } from './services/domain/product-events' import { createProviderService } from './services/domain/providers' import { createRequestLogService } from './services/domain/request-log' import { createStripeService } from './services/domain/stripe' import { createUserDeletionService } from './services/domain/user-deletion' +import { createVoicePackService } from './services/domain/voice-packs' import { createEnvelopeCrypto } from './utils/envelope-crypto' import { ApiError, createInternalError } from './utils/error' import { nanoid } from './utils/id' @@ -101,6 +106,7 @@ interface AppDeps { adminUsersService: AdminUsersService ttsMeter: FluxMeter requestLogService: RequestLogService + voicePackService: VoicePackService productEventService: ProductEventService configKV: ConfigKVService envelopeCrypto: EnvelopeCrypto @@ -227,6 +233,7 @@ export async function buildApp(deps: AppDeps) { productEventService: deps.productEventService, ttsMeter: deps.ttsMeter, llmRouter: deps.llmRouter, + voicePackService: deps.voicePackService, genAi: deps.otel?.genAi, revenue: deps.otel?.revenue, rateLimitMetrics: deps.otel?.rateLimit, @@ -339,6 +346,11 @@ export async function buildApp(deps: AppDeps) { */ .route('/api/v1/providers', createProviderRoutes(deps.providerService)) + /** + * Voice Pack routes expose the enabled curated library for binding. + */ + .route('/api/v1/voice-packs', createVoicePackRoutes(deps.voicePackService)) + /** * Chat routes are handled by the chat service. */ @@ -377,6 +389,14 @@ export async function buildApp(deps: AppDeps) { */ .route('/api/admin/users', createAdminUsersRoutes(deps.adminUsersService)) + /** + * Admin Voice Pack curation routes. + */ + .route('/api/admin/voice-packs', createAdminVoicePackRoutes({ + productEventService: deps.productEventService, + service: deps.voicePackService, + })) + /** * Admin LLM router config seeding/patching. Single entry point for * writing `LLM_ROUTER_CONFIG`, `UNSPEECH_UPSTREAM`, and the @@ -588,6 +608,11 @@ export async function createApp() { build: ({ dependsOn }) => createRequestLogService(dependsOn.db), }) + const voicePackService = injeca.provide('services:voicePack', { + dependsOn: { db }, + build: ({ dependsOn }) => createVoicePackService(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), @@ -655,13 +680,21 @@ export async function createApp() { // LLM router (KTD-5 in-process replacement for the knoway sidecar). // LLM_ROUTER_MASTER_KEY is required at env-parse time, so this provider // always builds a real router — the legacy `null` fallback path is gone. + // Shared by the TTS router (acquires slots) and the pool watermark gauge + // (reads the snapshot). Cluster-wide Redis state — the server is multi-instance. + const ttsConcurrencyLedger = injeca.provide('services:ttsConcurrencyLedger', { + dependsOn: { redis }, + build: ({ dependsOn }) => createConcurrencyLedger(dependsOn.redis), + }) + const llmRouter = injeca.provide('services:llmRouter', { - dependsOn: { configKV, envelopeCrypto, otel, redis }, + dependsOn: { configKV, envelopeCrypto, otel, redis, ttsConcurrencyLedger }, build: ({ dependsOn }) => createLlmRouterService({ configKV: dependsOn.configKV, envelopeCrypto: dependsOn.envelopeCrypto, gatewayMetrics: dependsOn.otel?.gateway ?? null, redis: dependsOn.redis, + concurrencyLedger: dependsOn.ttsConcurrencyLedger, }), }) @@ -675,6 +708,7 @@ export async function createApp() { fluxService, fluxTransactionService, requestLogService, + voicePackService, productEventService, stripeService, billingService, @@ -689,6 +723,7 @@ export async function createApp() { otel, userDeletionService, llmRouter, + ttsConcurrencyLedger, }) // Register the cluster-wide ObservableGauges for sessions / users. Each // replica polls the same DB (cached inside each gauge, in-flight coalesced); @@ -705,6 +740,7 @@ export async function createApp() { registerActiveSessionsGauge(resolved.otel.auth.activeSessions, resolved.db, resolved.otel.observability.metricReadErrors) registerDistinctActiveUsersGauge(resolved.otel.auth.distinctActiveUsers, resolved.db, resolved.otel.observability.metricReadErrors) registerRollingActiveUsersGauge(resolved.otel.auth.rollingActiveUsers, resolved.db, resolved.otel.observability.metricReadErrors) + registerTtsPoolGauge(resolved.otel.gateway.poolInflight, resolved.ttsConcurrencyLedger, resolved.otel.observability.metricReadErrors) } const { app, injectWebSocket } = await buildApp({ @@ -716,6 +752,7 @@ export async function createApp() { fluxService: resolved.fluxService, fluxTransactionService: resolved.fluxTransactionService, stripeService: resolved.stripeService, + voicePackService: resolved.voicePackService, billingService: resolved.billingService, adminFluxGrantsService: resolved.adminFluxGrantsService, adminRouterConfigService: resolved.adminRouterConfigService, diff --git a/apps/server/src/otel/gauges/tts-pool.test.ts b/apps/server/src/otel/gauges/tts-pool.test.ts new file mode 100644 index 000000000..6ed6fb634 --- /dev/null +++ b/apps/server/src/otel/gauges/tts-pool.test.ts @@ -0,0 +1,118 @@ +import type { GatewayMetrics, ObservabilityMetrics } from '..' +import type { ConcurrencyLedger } from '../../services/domain/llm-router/concurrency-ledger' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { registerTtsPoolGauge } from './tts-pool' + +/** + * Capture the callback registered via `gauge.addCallback` plus a spyable + * `observe` so tests can drive OTel collection cycles by hand. + * + * @example + * const { gauge, observe, run } = makeGauge() + * registerTtsPoolGauge(gauge, ledger, errs) + * await run() + * expect(observe).toHaveBeenCalledWith(3, { app_id: 'app-1' }) + */ +function makeGauge() { + let cb: ((result: { observe: (v: number, attrs: Record) => void }) => void | Promise) | null = null + const observe = vi.fn() + const gauge = { + addCallback: vi.fn((fn: typeof cb) => { cb = fn }), + } as unknown as GatewayMetrics['poolInflight'] + return { + gauge, + observe, + run: async () => { + if (!cb) + throw new Error('no callback registered') + await cb({ observe }) + }, + } +} + +function makeLedger(snapshot: () => Promise>): ConcurrencyLedger { + return { + tryAcquire: vi.fn(), + release: vi.fn(), + markSaturated: vi.fn(), + isSaturated: vi.fn(), + currentInflight: vi.fn(), + snapshot: vi.fn(snapshot), + } as unknown as ConcurrencyLedger +} + +function makeReadErrors() { + const add = vi.fn() + return { metricReadErrors: { add } as unknown as ObservabilityMetrics['metricReadErrors'], add } +} + +describe('registerTtsPoolGauge', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('observes one point per pool with the app_id attribute', async () => { + const ledger = makeLedger(async () => [ + { poolId: 'app-1', inflight: 3 }, + { poolId: 'app-2', inflight: 7 }, + ]) + const { metricReadErrors } = makeReadErrors() + const { gauge, observe, run } = makeGauge() + + registerTtsPoolGauge(gauge, ledger, metricReadErrors) + await run() + + expect(observe).toHaveBeenCalledTimes(2) + expect(observe).toHaveBeenCalledWith(3, { app_id: 'app-1' }) + expect(observe).toHaveBeenCalledWith(7, { app_id: 'app-2' }) + }) + + it('does not observe and records a read error when the snapshot fails', async () => { + // Letting the gauge skip an export cycle lets Prometheus staleness expose the + // outage instead of masking it with a stale value. + const ledger = makeLedger(async () => { + throw new Error('redis down') + }) + const { metricReadErrors, add } = makeReadErrors() + const { gauge, observe, run } = makeGauge() + + registerTtsPoolGauge(gauge, ledger, metricReadErrors) + await run() + + expect(observe).not.toHaveBeenCalled() + expect(add).toHaveBeenCalledWith(1, { metric: 'airi.gen_ai.gateway.pool.inflight' }) + }) + + it('serves the cached snapshot within the 10s TTL without re-reading Redis', async () => { + const ledger = makeLedger(async () => [{ poolId: 'app-1', inflight: 1 }]) + const { metricReadErrors } = makeReadErrors() + const { gauge, observe, run } = makeGauge() + + registerTtsPoolGauge(gauge, ledger, metricReadErrors) + await run() + vi.advanceTimersByTime(5_000) + await run() + + expect(ledger.snapshot).toHaveBeenCalledTimes(1) + expect(observe).toHaveBeenCalledTimes(2) + }) + + it('re-reads Redis after the cache TTL expires', async () => { + const ledger = makeLedger(async () => [{ poolId: 'app-1', inflight: 1 }]) + const { metricReadErrors } = makeReadErrors() + const { gauge, run } = makeGauge() + + registerTtsPoolGauge(gauge, ledger, metricReadErrors) + await run() + vi.advanceTimersByTime(10_001) + await run() + + expect(ledger.snapshot).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/server/src/otel/gauges/tts-pool.ts b/apps/server/src/otel/gauges/tts-pool.ts new file mode 100644 index 000000000..0620e0a1a --- /dev/null +++ b/apps/server/src/otel/gauges/tts-pool.ts @@ -0,0 +1,82 @@ +import type { GatewayMetrics, ObservabilityMetrics } from '..' +import type { ConcurrencyLedger } from '../../services/domain/llm-router/concurrency-ledger' + +import { useLogger } from '@guiiai/logg' + +/** + * Wire the `airi.gen_ai.gateway.pool.inflight` ObservableGauge to the Redis-backed + *pool concurrency ledger, emitting one series per app_id. + * + * Use when: + * - Assembling DI in `createApp()`, exactly once per process, only when OTel is + * enabled. + * + * Expects: + * - `gauge` is the ObservableGauge handle from `initOtel`. + * - `ledger` is the same concurrency ledger the TTS router acquires slots on. + * - `metricReadErrors` is the shared self-monitoring counter, labelled by the + * originating metric name. + * + * Multi-replica note: + * - Cluster-wide gauge — every replica reads the same Redis counters and reports + * the same per-pool value. Dashboards MUST aggregate with `avg()`, NOT `sum()`. + * See observability-conventions.md. + * + * Concurrency: + * - Multiple OTel collection cycles can race. The in-flight promise lock keeps at + * most one Redis snapshot in flight per process; concurrent callbacks await the + * same result rather than stampeding Redis. + * + * Failure mode: + * - On Redis error we increment `airi.observability.read_errors{metric}` and + * intentionally DO NOT observe — letting the gauge skip an export cycle lets + * Prometheus staleness expose the outage instead of masking it with a stale value. + */ +export function registerTtsPoolGauge( + gauge: GatewayMetrics['poolInflight'], + ledger: ConcurrencyLedger, + metricReadErrors: ObservabilityMetrics['metricReadErrors'], +) { + const log = useLogger('tts-pool-gauge').useGlobalConfig() + const CACHE_TTL_MS = 10_000 + + let cachedAt = 0 + let cachedSnapshot: Array<{ poolId: string, inflight: number }> = [] + let refreshInFlight: Promise | null = null + + async function refresh(): Promise { + try { + cachedSnapshot = await ledger.snapshot() + cachedAt = Date.now() + return true + } + catch (err) { + log.withError(err).warn('Failed to read tts pool snapshot for gauge') + metricReadErrors.add(1, { metric: 'airi.gen_ai.gateway.pool.inflight' }) + return false + } + } + + gauge.addCallback(async (result) => { + const now = Date.now() + + if (cachedAt !== 0 && now - cachedAt < CACHE_TTL_MS) { + for (const { poolId, inflight } of cachedSnapshot) + result.observe(inflight, { app_id: poolId }) + return + } + + if (!refreshInFlight) { + refreshInFlight = refresh().finally(() => { + refreshInFlight = null + }) + } + const ok = await refreshInFlight + + if (ok) { + for (const { poolId, inflight } of cachedSnapshot) + result.observe(inflight, { app_id: poolId }) + } + // else: deliberately do nothing — let Prometheus staleness expose the outage. + }) +} diff --git a/apps/server/src/otel/index.ts b/apps/server/src/otel/index.ts index daa8df756..b5d1d60d2 100644 --- a/apps/server/src/otel/index.ts +++ b/apps/server/src/otel/index.ts @@ -25,6 +25,9 @@ import { METRIC_AIRI_GEN_AI_GATEWAY_DECRYPT_FAILURES, METRIC_AIRI_GEN_AI_GATEWAY_FALLBACK_COUNT, METRIC_AIRI_GEN_AI_GATEWAY_KEY_EXHAUSTED_COUNT, + METRIC_AIRI_GEN_AI_GATEWAY_POOL_INFLIGHT, + METRIC_AIRI_GEN_AI_GATEWAY_POOL_SATURATION_MARKED, + METRIC_AIRI_GEN_AI_GATEWAY_POOL_SLOT_REJECTED, METRIC_AIRI_GEN_AI_GATEWAY_SAME_STATUS_EXHAUSTION, METRIC_AIRI_GEN_AI_GATEWAY_SUBSCRIBER_STATE, METRIC_AIRI_GEN_AI_GATEWAY_UPSTREAM_ERRORS, @@ -276,6 +279,27 @@ export interface GatewayMetrics { * >0 = forged or replayed message — investigate Redis access boundary. */ configInvalidHmac: Counter + /** + * Capacity-aware TTS routing skipped a pool because its app_id was already at + * the concurrency cap (the pre-read said free but the atomic acquire lost the + * race, or every pool was full). Labels: `provider`, `app_id`. + * + * Recommended alert: sustained rate relative to TTS request volume means the + *pool is undersized — add app_ids or raise the cap. + */ + poolSlotRejected: Counter + /** + * Apool was circuit-broken after exhausting with a 429 (app_id concurrency + * exceeded upstream-side). Labels: `provider`, `app_id`. A pool with a high + * mark rate is being driven past its real upstream limit. + */ + poolSaturationMarked: Counter + /** + * Cluster-wide gauge of current in-flight requests per pool, sourced from + * Redis. Label: `app_id`. Every replica reports the same value — dashboards + * MUST aggregate with `avg()`, NOT `sum()` (see observability-conventions.md). + */ + poolInflight: ObservableGauge } export interface EmailMetrics { @@ -501,6 +525,15 @@ export function initOtel(env: Env): OtelInstance | null { configInvalidHmac: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_INVALID_HMAC, { description: 'Pub/Sub invalidation messages dropped due to HMAC mismatch (forged or replayed)', }), + poolSlotRejected: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_POOL_SLOT_REJECTED, { + description: 'Capacity-aware TTS routing skipped a pool already at its app_id concurrency cap', + }), + poolSaturationMarked: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_POOL_SATURATION_MARKED, { + description: 'TTSpool circuit-broken after exhausting with a 429 (app_id concurrency exceeded)', + }), + poolInflight: meter.createObservableGauge(METRIC_AIRI_GEN_AI_GATEWAY_POOL_INFLIGHT, { + description: 'In-flight TTS requests per pool sourced from Redis (cluster-wide; dashboard must use avg(), not sum())', + }), } const email: EmailMetrics = { diff --git a/apps/server/src/routes/admin/voice-packs/index.ts b/apps/server/src/routes/admin/voice-packs/index.ts new file mode 100644 index 000000000..e527a10f8 --- /dev/null +++ b/apps/server/src/routes/admin/voice-packs/index.ts @@ -0,0 +1,116 @@ +import type { ProductEventService } from '../../../services/domain/product-events' +import type { VoicePackService } from '../../../services/domain/voice-packs' +import type { HonoEnv } from '../../../types/hono' + +import { Hono } from 'hono' +import { safeParse } from 'valibot' + +import { adminGuard } from '../../../middlewares/admin-guard' +import { authGuard } from '../../../middlewares/auth' +import { CreateVoicePackInputSchema, UpdateVoicePackInputSchema } from '../../../services/domain/voice-packs' +import { createBadRequestError, createNotFoundError } from '../../../utils/error' + +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, + })) +} + +/** + * Admin CRUD routes for curated Voice Packs. + * + * Mounted at `/api/admin/voice-packs`. Disabling is soft (`enabled=false`) so + * existing character-card snapshots never lose their historical definition. + */ +export function createAdminVoicePackRoutes(deps: { + productEventService: ProductEventService + service: VoicePackService +}) { + return new Hono() + .use('*', authGuard) + .use('*', adminGuard) + .get('/', async (c) => { + const packs = await deps.service.list() + return c.json(packs) + }) + .post('/', async (c) => { + const user = c.get('user')! + const raw = await c.req.json().catch(() => null) + if (raw == null) + throw createBadRequestError('Request body must be JSON', 'INVALID_BODY') + + const parsed = safeParse(CreateVoicePackInputSchema, raw) + if (!parsed.success) + throw createBadRequestError('Invalid request body', 'INVALID_BODY', parseIssues(parsed.issues)) + + const created = await deps.service.create(parsed.output) + void deps.productEventService.track({ + userId: user.id, + feature: 'voice_pack', + action: 'voice_pack_created', + status: 'succeeded', + source: 'admin.voice_packs', + metadata: { + voice_pack_id: created.id, + provider: created.provider, + model: created.model, + tts_model_id: created.ttsModelId, + cost_multiplier: created.costMultiplier, + }, + }) + return c.json(created, 201) + }) + .patch('/:id', async (c) => { + const user = c.get('user')! + const raw = await c.req.json().catch(() => null) + if (raw == null) + throw createBadRequestError('Request body must be JSON', 'INVALID_BODY') + + const parsed = safeParse(UpdateVoicePackInputSchema, raw) + if (!parsed.success) + throw createBadRequestError('Invalid request body', 'INVALID_BODY', parseIssues(parsed.issues)) + + const updated = await deps.service.update(c.req.param('id'), parsed.output) + if (!updated) + throw createNotFoundError('Voice Pack not found') + + void deps.productEventService.track({ + userId: user.id, + feature: 'voice_pack', + action: 'voice_pack_updated', + status: 'succeeded', + source: 'admin.voice_packs', + metadata: { + voice_pack_id: updated.id, + provider: updated.provider, + model: updated.model, + tts_model_id: updated.ttsModelId, + cost_multiplier: updated.costMultiplier, + enabled: updated.enabled, + }, + }) + return c.json(updated) + }) + .post('/:id/disable', async (c) => { + const user = c.get('user')! + const disabled = await deps.service.disable(c.req.param('id')) + if (!disabled) + throw createNotFoundError('Voice Pack not found or already disabled') + + void deps.productEventService.track({ + userId: user.id, + feature: 'voice_pack', + action: 'voice_pack_disabled', + status: 'succeeded', + source: 'admin.voice_packs', + metadata: { + voice_pack_id: disabled.id, + provider: disabled.provider, + model: disabled.model, + tts_model_id: disabled.ttsModelId, + }, + }) + return c.json(disabled) + }) +} diff --git a/apps/server/src/routes/admin/voice-packs/route.test.ts b/apps/server/src/routes/admin/voice-packs/route.test.ts new file mode 100644 index 000000000..63cc28f06 --- /dev/null +++ b/apps/server/src/routes/admin/voice-packs/route.test.ts @@ -0,0 +1,196 @@ +import type { VoicePack } from '../../../schemas/voice-packs' +import type { ProductEventService } from '../../../services/domain/product-events' +import type { CreateVoicePackInput, UpdateVoicePackInput, VoicePackService } from '../../../services/domain/voice-packs' +import type { HonoEnv } from '../../../types/hono' + +import { Hono } from 'hono' +import { describe, expect, it, vi } from 'vitest' + +import { createAdminVoicePackRoutes } 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 createService() { + const makePack = (overrides: Partial = {}): VoicePack => ({ + id: 'vp-1', + name: 'Neuro Sama', + description: null, + provider: 'volcengine', + model: 'seed-tts-2.0', + voiceId: 'voice-neuro', + ttsModelId: 'volcengine/neuro-pool', + params: {}, + costMultiplier: 1.5, + enabled: true, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }) + + return { + list: vi.fn(async () => []), + create: vi.fn(async (input: CreateVoicePackInput) => makePack(input)), + update: vi.fn(async (_id: string, input: UpdateVoicePackInput): Promise => makePack(input)), + disable: vi.fn(async (id: string): Promise => makePack({ id, enabled: false })), + listEnabled: vi.fn(), + findById: vi.fn(), + } satisfies VoicePackService +} + +function createProductEventService(): ProductEventService { + return { + track: vi.fn(async () => undefined), + countDistinctUsersByFeature: vi.fn(async () => []), + } +} + +function createTestApp(service: VoicePackService, user: MockUser | null, productEventService = createProductEventService()) { + return new Hono() + .use('*', async (c, next) => { + c.set('user', user as HonoEnv['Variables']['user']) + await next() + }) + .route('/api/admin/voice-packs', createAdminVoicePackRoutes({ + productEventService, + service, + })) + .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 voice packs — auth guards', () => { + it('returns 401 when unauthenticated', async () => { + // @example no session -> admin curation is not reachable. + const service = createService() + const app = createTestApp(service, null) + const res = await jsonRequest(app, 'GET', '/api/admin/voice-packs') + + expect(res.status).toBe(401) + expect(service.list).not.toHaveBeenCalled() + }) + + it('returns 403 for a non-admin user', async () => { + // @example ordinary authenticated user -> forbidden. + const service = createService() + const app = createTestApp(service, { id: 'u', email: 'u@example.com', role: 'user' }) + const res = await jsonRequest(app, 'GET', '/api/admin/voice-packs') + + expect(res.status).toBe(403) + expect(service.list).not.toHaveBeenCalled() + }) +}) + +describe('admin voice packs — CRUD', () => { + it('lists all packs for admins', async () => { + // @example admin list includes disabled rows; service owns filtering behavior. + const service = createService() + const app = createTestApp(service, ADMIN) + const res = await jsonRequest(app, 'GET', '/api/admin/voice-packs') + + expect(res.status).toBe(200) + expect(service.list).toHaveBeenCalled() + }) + + it('creates a pack with validated fields', async () => { + // @example valid body -> route forwards normalized params and enabled default. + const service = createService() + const productEventService = createProductEventService() + const app = createTestApp(service, ADMIN, productEventService) + const body = { + name: 'Neuro Sama', + provider: 'volcengine', + model: 'seed-tts-2.0', + voiceId: 'voice-neuro', + ttsModelId: 'volcengine/neuro-pool', + params: { pitch: '+20%' }, + costMultiplier: 1.5, + } + const res = await jsonRequest(app, 'POST', '/api/admin/voice-packs', body) + + expect(res.status).toBe(201) + expect(service.create).toHaveBeenCalledWith({ ...body, enabled: true }) + expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'admin-1', + feature: 'voice_pack', + action: 'voice_pack_created', + status: 'succeeded', + source: 'admin.voice_packs', + metadata: expect.objectContaining({ + voice_pack_id: 'vp-1', + cost_multiplier: 1.5, + }), + })) + }) + + it('rejects invalid cost multiplier on create', async () => { + // @example negative cost multiplier -> 400 before service call. + const service = createService() + const app = createTestApp(service, ADMIN) + const res = await jsonRequest(app, 'POST', '/api/admin/voice-packs', { + name: 'Bad', + provider: 'volcengine', + model: 'seed-tts-2.0', + voiceId: 'voice-neuro', + ttsModelId: 'volcengine/neuro-pool', + params: {}, + costMultiplier: -1, + }) + + expect(res.status).toBe(400) + expect(service.create).not.toHaveBeenCalled() + }) + + it('updates a pack and maps missing ids to 404', async () => { + // @example known id -> update; missing id -> not found. + const service = createService() + const app = createTestApp(service, ADMIN) + const ok = await jsonRequest(app, 'PATCH', '/api/admin/voice-packs/vp-1', { name: 'Updated' }) + + expect(ok.status).toBe(200) + expect(service.update).toHaveBeenCalledWith('vp-1', { name: 'Updated' }) + + service.update.mockResolvedValueOnce(null) + const missing = await jsonRequest(app, 'PATCH', '/api/admin/voice-packs/missing', { name: 'Updated' }) + expect(missing.status).toBe(404) + }) + + it('soft-disables a pack', async () => { + // @example disable endpoint does not delete; it returns the disabled row. + const service = createService() + const productEventService = createProductEventService() + const app = createTestApp(service, ADMIN, productEventService) + const res = await jsonRequest(app, 'POST', '/api/admin/voice-packs/vp-1/disable') + + expect(res.status).toBe(200) + expect(service.disable).toHaveBeenCalledWith('vp-1') + expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'admin-1', + feature: 'voice_pack', + action: 'voice_pack_disabled', + status: 'succeeded', + source: 'admin.voice_packs', + metadata: expect.objectContaining({ + voice_pack_id: 'vp-1', + }), + })) + expect(await res.json()).toMatchObject({ id: 'vp-1', enabled: false }) + }) +}) 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 b6242459b..dc6544161 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 @@ -20,6 +20,7 @@ export function speechGeneration(deps: V1RouteDeps): GatewayCallback<'speech.gen productEventService: deps.productEventService, requestLogService: deps.requestLogService, ttsMeter: deps.ttsMeter, + voicePackService: deps.voicePackService, }) return context => speechService.handleSpeechRequest(context.input) diff --git a/apps/server/src/routes/openai/v1/route.test.ts b/apps/server/src/routes/openai/v1/route.test.ts index da98d4267..fb484c794 100644 --- a/apps/server/src/routes/openai/v1/route.test.ts +++ b/apps/server/src/routes/openai/v1/route.test.ts @@ -3,7 +3,9 @@ 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 { ProductEventService } from '../../../services/domain/product-events' import type { RequestLogService } from '../../../services/domain/request-log' +import type { VoicePackService } from '../../../services/domain/voice-packs' import type { HonoEnv } from '../../../types/hono' import { Hono } from 'hono' @@ -128,6 +130,25 @@ function createMockLlmRouter(impl?: Partial): LlmRouterService } as LlmRouterService } +function createMockProductEventService(): ProductEventService { + return { + track: vi.fn(async () => undefined), + countDistinctUsersByFeature: vi.fn(async () => []), + } +} + +function createMockVoicePackService(impl?: Partial): VoicePackService { + return { + listEnabled: vi.fn(async () => []), + list: vi.fn(async () => []), + create: vi.fn(), + update: vi.fn(), + disable: vi.fn(), + findById: vi.fn(async () => null), + ...impl, + } as unknown as VoicePackService +} + function createTestApp( fluxService: FluxService, configKV: ConfigKVService, @@ -136,18 +157,18 @@ function createTestApp( ttsMeter?: ReturnType, llmRouter?: LlmRouterService, llmTracing = createMockLlmTracing(), + productEventService = createMockProductEventService(), + voicePackService = createMockVoicePackService(), ) { const { openaiRoutes, audioRoutes } = createV1Routes({ fluxService, billingService: billingService ?? createMockBillingService(), configKV, requestLogService: requestLogService ?? createMockRequestLogService(), - productEventService: { - track: vi.fn(async () => undefined), - countDistinctUsersByFeature: vi.fn(async () => []), - }, + productEventService, ttsMeter: ttsMeter ?? createMockTtsMeter(), llmRouter: llmRouter ?? createMockLlmRouter(), + voicePackService, genAi: null, revenue: null, rateLimitMetrics: null, @@ -658,6 +679,82 @@ describe('v1CompletionsRoutes', () => { ) }) + /** + * @example + * POST /api/v1/audio/speech { "speed": 1.2, "extra_body": { "voice_pack": { "pitch": 20 } } } + */ + it('forwards TTS speed and Voice Pack prosody options to the router input', async () => { + const routeTts = vi.fn(async () => new Response(new Uint8Array([1]), { + status: 200, + headers: { 'Content-Type': 'audio/mpeg' }, + })) + + const app = createTestApp( + createMockFluxService(), + createMockConfigKV({ DEFAULT_TTS_MODEL: 'microsoft/v1' }), + undefined, + undefined, + undefined, + createMockLlmRouter({ routeTts }), + createMockLlmTracing(), + createMockProductEventService(), + createMockVoicePackService({ + findById: vi.fn(async () => ({ + id: 'vp-azure', + name: 'Azure', + description: null, + provider: 'azure', + model: 'microsoft/v1', + voiceId: 'en-US-AvaMultilingualNeural', + ttsModelId: 'microsoft/v1', + params: {}, + costMultiplier: 1.5, + enabled: true, + createdAt: new Date(), + updatedAt: new Date(), + })), + }), + ) + + 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: 'en-US-AvaMultilingualNeural', + speed: 1.2, + extra_body: { + voice_pack: { + pack_id: 'vp-azure', + cost_multiplier: 1.5, + pitch: 20, + volume: 5, + }, + }, + }), + }), + { user: testUser } as any, + ) + + expect(routeTts).toHaveBeenCalledWith( + expect.objectContaining({ + modelName: 'microsoft/v1', + input: expect.objectContaining({ + text: 'test', + voice: 'en-US-AvaMultilingualNeural', + speed: 1.2, + extraOptions: { + pitch: 20, + volume: 5, + }, + }), + }), + expect.any(Object), + ) + }) + it('should bill per character with minimum charge', async () => { globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([1]), { status: 200, @@ -680,6 +777,73 @@ describe('v1CompletionsRoutes', () => { expect(billingService.consumeFluxForLLM).not.toHaveBeenCalled() }) + /** + * @example + * POST /api/v1/audio/speech { "input": "hello", "extra_body": { "voice_pack": { "cost_multiplier": 2 } } } + */ + it('uses Voice Pack cost multiplier for affordability and billing units', async () => { + globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([1]), { + status: 200, + headers: { 'Content-Type': 'audio/mpeg' }, + })) + + const ttsMeter = createMockTtsMeter() + const voicePackService = createMockVoicePackService({ + findById: vi.fn(async () => ({ + id: 'vp-premium', + name: 'Premium', + description: null, + provider: 'azure', + model: 'microsoft/v1', + voiceId: 'alloy', + ttsModelId: 'tts-1', + params: {}, + costMultiplier: 2, + enabled: false, + createdAt: new Date(), + updatedAt: new Date(), + })), + }) + const app = createTestApp( + createMockFluxService(), + createMockConfigKV(), + undefined, + undefined, + ttsMeter, + undefined, + createMockLlmTracing(), + createMockProductEventService(), + voicePackService, + ) + + await app.fetch( + new Request('http://localhost/api/v1/audio/speech', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'auto', + input: 'hello', + voice: 'alloy', + extra_body: { + voice_pack: { + pack_id: 'vp-premium', + cost_multiplier: 2, + }, + }, + }), + }), + { user: testUser } as any, + ) + + expect(ttsMeter.assertCanAfford).toHaveBeenCalledWith('user-1', 10, 100) + expect(ttsMeter.accumulate).toHaveBeenCalledWith(expect.objectContaining({ + units: 10, + metadata: expect.objectContaining({ + costMultiplier: 2, + }), + })) + }) + it('should not charge when routeTts upstream returns error', async () => { const llmRouter = createMockLlmRouter({ routeTts: vi.fn(async () => new Response('{"error":"service down"}', { @@ -703,6 +867,49 @@ describe('v1CompletionsRoutes', () => { expect(billingService.consumeFluxForLLM).not.toHaveBeenCalled() }) + /** + * @example + * routeTts throws ApiError(429, 'TOO_MANY_REQUESTS', 'Too many requests') + */ + it('records routeTts ApiError status and reason in product events', async () => { + const productEventService = createMockProductEventService() + const llmRouter = createMockLlmRouter({ + routeTts: vi.fn(async () => { + throw new ApiError(429, 'TOO_MANY_REQUESTS', 'Too many requests') + }) as any, + }) + const app = createTestApp( + createMockFluxService(), + createMockConfigKV(), + undefined, + undefined, + undefined, + llmRouter, + createMockLlmTracing(), + productEventService, + ) + + 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: 'hello', voice: 'alloy' }), + }), + { user: testUser } as any, + ) + + expect(res.status).toBe(429) + expect(productEventService.track).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'speech_failed', + reason: 'TOO_MANY_REQUESTS', + metadata: expect.objectContaining({ + http_status: 429, + }), + }), + ) + }) + it('should return 402 when flux is insufficient', async () => { const app = createTestApp( createMockFluxService(0), diff --git a/apps/server/src/routes/openai/v1/types.ts b/apps/server/src/routes/openai/v1/types.ts index abc8583d5..b0fc4e5c1 100644 --- a/apps/server/src/routes/openai/v1/types.ts +++ b/apps/server/src/routes/openai/v1/types.ts @@ -7,6 +7,7 @@ import type { LlmRouterService } from '../../../services/domain/llm-router' import type { ChatGenerationTrace, TtsGenerationTrace } from '../../../services/domain/llm-tracing' import type { ProductEventService } from '../../../services/domain/product-events' import type { RequestLogService } from '../../../services/domain/request-log' +import type { VoicePackService } from '../../../services/domain/voice-packs' import { startChatGeneration, startTtsGeneration } from '../../../services/domain/llm-tracing' @@ -23,6 +24,7 @@ export interface V1RouteDeps { productEventService: ProductEventService ttsMeter: FluxMeter llmRouter: LlmRouterService + voicePackService: VoicePackService genAi?: GenAiMetrics | null revenue?: RevenueMetrics | null rateLimitMetrics?: RateLimitMetrics | null diff --git a/apps/server/src/routes/voice-packs/index.ts b/apps/server/src/routes/voice-packs/index.ts new file mode 100644 index 000000000..b2d7a32c1 --- /dev/null +++ b/apps/server/src/routes/voice-packs/index.ts @@ -0,0 +1,22 @@ +import type { VoicePackService } from '../../services/domain/voice-packs' +import type { HonoEnv } from '../../types/hono' + +import { Hono } from 'hono' + +import { authGuard } from '../../middlewares/auth' + +/** + * User-facing Voice Pack routes. + * + * Mounted at `/api/v1/voice-packs`. Only enabled packs are exposed so disabled + * curated entries remain available to historical character snapshots but cannot + * be newly selected. + */ +export function createVoicePackRoutes(service: VoicePackService) { + return new Hono() + .use('*', authGuard) + .get('/', async (c) => { + const packs = await service.listEnabled() + return c.json(packs) + }) +} diff --git a/apps/server/src/routes/voice-packs/route.test.ts b/apps/server/src/routes/voice-packs/route.test.ts new file mode 100644 index 000000000..538051ba1 --- /dev/null +++ b/apps/server/src/routes/voice-packs/route.test.ts @@ -0,0 +1,56 @@ +import type { VoicePackService } from '../../services/domain/voice-packs' +import type { HonoEnv } from '../../types/hono' + +import { Hono } from 'hono' +import { describe, expect, it, vi } from 'vitest' + +import { createVoicePackRoutes } from '.' +import { ApiError } from '../../utils/error' + +function createTestApp(service: VoicePackService, user: { id: string } | null) { + return new Hono() + .use('*', async (c, next) => { + c.set('user', user as HonoEnv['Variables']['user']) + await next() + }) + .route('/api/v1/voice-packs', createVoicePackRoutes(service)) + .onError((err, c) => { + if (err instanceof ApiError) + return c.json({ error: err.errorCode }, err.statusCode) + return c.json({ error: 'internal', message: (err as Error).message }, 500) + }) +} + +function createService() { + return { + listEnabled: vi.fn(async () => [{ id: 'vp-1', name: 'Enabled', enabled: true }]), + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + disable: vi.fn(), + findById: vi.fn(), + } as unknown as VoicePackService +} + +describe('voice packs routes', () => { + it('requires auth before listing enabled packs', async () => { + // @example anonymous users cannot enumerate curated packs. + const service = createService() + const app = createTestApp(service, null) + const res = await app.request('/api/v1/voice-packs') + + expect(res.status).toBe(401) + expect(service.listEnabled).not.toHaveBeenCalled() + }) + + it('lists only enabled packs through the service', async () => { + // @example client binding surface delegates to enabled-only service method. + const service = createService() + const app = createTestApp(service, { id: 'u-1' }) + const res = await app.request('/api/v1/voice-packs') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual([{ id: 'vp-1', name: 'Enabled', enabled: true }]) + expect(service.listEnabled).toHaveBeenCalled() + }) +}) diff --git a/apps/server/src/schemas/index.ts b/apps/server/src/schemas/index.ts index deeb0b522..c9f44062e 100644 --- a/apps/server/src/schemas/index.ts +++ b/apps/server/src/schemas/index.ts @@ -8,3 +8,4 @@ export * from './product-events' export * from './providers' export * from './stripe' export * from './user-character' +export * from './voice-packs' diff --git a/apps/server/src/schemas/voice-packs.ts b/apps/server/src/schemas/voice-packs.ts new file mode 100644 index 000000000..c5b0e1f4f --- /dev/null +++ b/apps/server/src/schemas/voice-packs.ts @@ -0,0 +1,30 @@ +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' + +import { boolean, jsonb, pgTable, real, text, timestamp } from 'drizzle-orm/pg-core' + +import { nanoid } from '../utils/id' + +export type VoicePackParams = Record + +export const voicePacks = pgTable( + 'voice_packs', + { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + name: text('name').notNull(), + description: text('description'), + + provider: text('provider').notNull(), + model: text('model').notNull(), + voiceId: text('voice_id').notNull(), + ttsModelId: text('tts_model_id').notNull(), + params: jsonb('params').notNull().$type().default({}), + costMultiplier: real('cost_multiplier').notNull().default(1), + enabled: boolean('enabled').notNull().default(true), + + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, +) + +export type VoicePack = InferSelectModel +export type NewVoicePack = InferInsertModel diff --git a/apps/server/src/services/adapters/config-kv.ts b/apps/server/src/services/adapters/config-kv.ts index 46a26cdda..f3539eeef 100644 --- a/apps/server/src/services/adapters/config-kv.ts +++ b/apps/server/src/services/adapters/config-kv.ts @@ -58,6 +58,13 @@ export const ttsUpstreamSchema = object({ baseURL: pipe(string(), nonEmpty('tts.upstreams[].baseURL must not be empty')), keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'tts.upstreams[].keys must contain at least 1 entry')), adapterParams: optional(record(string(), any()), {}), + // Per-app_id concurrency cap for the pool load balancer. One upstream maps to + // one app_id (Volcengine `adapterParams.appid`), capped by the provider at a + // small number (e.g. 10). When set on any upstream of a model, the router + // switches from fixed-order fallback to capacity-aware routing across pools. + // Absent = unlimited: that model keeps the original fixed-order behavior and + // makes zero Redis calls (no regression for existing single-app configs). + maxConcurrency: optional(pipe(number(), check(v => v >= 1, 'tts.upstreams[].maxConcurrency must be >= 1 when set'))), }) export const streamingTtsUpstreamSchema = object({ diff --git a/apps/server/src/services/adapters/tts/azure.ts b/apps/server/src/services/adapters/tts/azure.ts index add94bee3..f492439b0 100644 --- a/apps/server/src/services/adapters/tts/azure.ts +++ b/apps/server/src/services/adapters/tts/azure.ts @@ -2,7 +2,7 @@ import type { Voice } from 'unspeech' import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types' -import { buildMicrosoftSsml, inferMicrosoftContentType, isMicrosoftVoiceId, resolveMicrosoftOutputFormat } from 'unspeech' +import { inferMicrosoftContentType, isMicrosoftVoiceId, resolveMicrosoftOutputFormat } from 'unspeech' import { createBadRequestError, createInternalError, createServiceUnavailableError } from '../../../utils/error' import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech' @@ -41,7 +41,10 @@ export const azureAdapter: TtsAdapter = { const ssml = disableSsml ? input.text - : buildMicrosoftSsml(input.text, voice, input.speed) + : buildAzureSsml(input.text, voice, input.speed, { + pitch: typeof input.extraOptions?.pitch === 'number' ? input.extraOptions.pitch : undefined, + volume: typeof input.extraOptions?.volume === 'number' ? input.extraOptions.volume : undefined, + }) const region = ctx.adapterParams?.region if (typeof region !== 'string' || !region) @@ -78,3 +81,69 @@ export const azureAdapter: TtsAdapter = { }) }, } + +/** + * Builds Azure-compatible SSML, preserving Voice Pack prosody settings. + * + * NOTICE: + * `unspeech` owns the canonical Microsoft helpers, but the currently consumed + * helper surface only lets AIRI pass speed. Voice Pack pitch and volume must be + * encoded before the request reaches unspeech because AIRI sends pre-built SSML + * with `disable_ssml: true`. + * Source/context: this adapter's `extraOptions.pitch` and `extraOptions.volume` + * contract, covered by `azureAdapter.send` tests. + * Removal condition: delete this helper once `unspeech` exposes a + * `buildMicrosoftSsml` overload that accepts pitch and volume. + */ +function buildAzureSsml( + text: string, + voice: string, + speed: number | undefined, + options: { + pitch?: number + volume?: number + }, +): string { + const safe = escapeForSsml(text) + const rate = speedToProsodyRate(speed) + const pitch = percentToProsodyValue(options.pitch) + const volume = percentToProsodyValue(options.volume) + const prosodyAttrs = [ + rate ? `rate='${rate}'` : undefined, + pitch ? `pitch='${pitch}'` : undefined, + volume ? `volume='${volume}'` : undefined, + ].filter(Boolean).join(' ') + const inner = prosodyAttrs + ? `${safe}` + : safe + + return `${inner}` +} + +function speedToProsodyRate(speed: number | undefined): string { + if (speed == null || speed === 1) + return '' + const delta = Math.round((speed - 1) * 100) + if (delta === 0) + return '' + return delta > 0 ? `+${delta}%` : `${delta}%` +} + +function percentToProsodyValue(value: number | undefined): string { + if (value == null) + return '' + if (value > 0) + return `+${value}%` + if (value < 0) + return `${value}%` + return '0%' +} + +function escapeForSsml(text: string): string { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('\'', ''') +} diff --git a/apps/server/src/services/adapters/tts/dashscope-cosyvoice.test.ts b/apps/server/src/services/adapters/tts/dashscope-cosyvoice.test.ts index f125c76d0..64e6929c9 100644 --- a/apps/server/src/services/adapters/tts/dashscope-cosyvoice.test.ts +++ b/apps/server/src/services/adapters/tts/dashscope-cosyvoice.test.ts @@ -88,6 +88,33 @@ describe('dashscopeCosyvoiceAdapter', () => { expect(fetchImpl).not.toHaveBeenCalled() }) + /** + * @example + * dashscopeCosyvoiceAdapter.send({ text: 'hi', extraOptions: { volume: 5 } }, ctx) + */ + it('fails fast when Voice Pack pitch or volume params reach DashScope cosyvoice', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(binaryResponse(new Uint8Array([0]))) + + await expect(dashscopeCosyvoiceAdapter.send( + { + text: 'hi', + voice: 'longxiaochun_v2', + extraOptions: { + volume: 5, + }, + }, + { + keyPlaintext: Buffer.from('sk-test', 'utf8'), + baseURL: 'https://dashscope-intl.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer', + unspeechBaseURL: UNSPEECH, + adapterParams: {}, + fetchImpl: fetchImpl as unknown as typeof fetch, + }, + )).rejects.toMatchObject({ statusCode: 400 }) + + expect(fetchImpl).not.toHaveBeenCalled() + }) + it('voice catalog is proxied through unspeech with the selected cosyvoice model', async () => { // The catalog itself is unspeech-owned now (embedded JSON in // unspeech/pkg/backend/alibaba/voices.go). This test only verifies the diff --git a/apps/server/src/services/adapters/tts/dashscope-cosyvoice.ts b/apps/server/src/services/adapters/tts/dashscope-cosyvoice.ts index 837455e8d..08eba0bd0 100644 --- a/apps/server/src/services/adapters/tts/dashscope-cosyvoice.ts +++ b/apps/server/src/services/adapters/tts/dashscope-cosyvoice.ts @@ -54,6 +54,12 @@ export const dashscopeCosyvoiceAdapter: TtsAdapter = { : DEFAULT_COSYVOICE_MODEL if (!input.voice) throw createBadRequestError('dashscope-cosyvoice voice is required', 'BAD_REQUEST') + if (typeof input.extraOptions?.pitch === 'number' || typeof input.extraOptions?.volume === 'number') { + throw createBadRequestError( + 'dashscope-cosyvoice does not support Voice Pack pitch or volume parameters', + 'BAD_REQUEST', + ) + } const voice = input.voice const format = input.responseFormat ?? DEFAULT_COSYVOICE_FORMAT diff --git a/apps/server/src/services/adapters/tts/index.test.ts b/apps/server/src/services/adapters/tts/index.test.ts index b71f960bc..194ce7a59 100644 --- a/apps/server/src/services/adapters/tts/index.test.ts +++ b/apps/server/src/services/adapters/tts/index.test.ts @@ -201,7 +201,15 @@ describe('azureAdapter.send', () => { })) as unknown as typeof fetch await adapter.send( - { text: 'hi there', voice: 'en-US-AvaMultilingualNeural', speed: 1.2 }, + { + text: 'hi there', + voice: 'en-US-AvaMultilingualNeural', + speed: 1.2, + extraOptions: { + pitch: 20, + volume: 5, + }, + }, { keyPlaintext: Buffer.from('azure-sub-key', 'utf8'), baseURL: 'https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1', @@ -220,7 +228,7 @@ describe('azureAdapter.send', () => { expect((body.extra_body as { disable_ssml?: boolean }).disable_ssml).toBe(true) // SSML is built on our side so speed survives — verify the prosody tag is in // the input field unspeech receives. - expect(body.input).toContain('') + expect(body.input).toContain('') expect(body.input).toContain('hi there') const headers = init.headers as Record expect(headers.Authorization).toBe('Bearer azure-sub-key') @@ -322,6 +330,34 @@ describe('volcengineAdapter.send', () => { expect(result.body).toBeInstanceOf(ArrayBuffer) }) + /** + * @example + * volcengineAdapter.send({ text: 'hi', extraOptions: { pitch: 20 } }, ctx) + */ + it('fails fast when Voice Pack pitch or volume params reach Volcengine', async () => { + const adapter = getAdapter('volcengine') + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([0x49]))) as unknown as typeof fetch + + await expect(adapter.send( + { + text: 'hi', + voice: 'BV001_streaming', + extraOptions: { + pitch: 20, + }, + }, + { + keyPlaintext: Buffer.from('volc-token', 'utf8'), + baseURL: 'https://openspeech.bytedance.com/api/v1/tts', + unspeechBaseURL: 'http://unspeech.local:5933', + adapterParams: { appid: 'APP-123' }, + fetchImpl, + }, + )).rejects.toMatchObject({ statusCode: 400 }) + + expect(fetchImpl).not.toHaveBeenCalled() + }) + it('rejects when adapterParams.appid is missing', async () => { const adapter = getAdapter('volcengine') const fetchImpl = vi.fn() as unknown as typeof fetch diff --git a/apps/server/src/services/adapters/tts/volcengine.ts b/apps/server/src/services/adapters/tts/volcengine.ts index 24677f975..1aa4ed165 100644 --- a/apps/server/src/services/adapters/tts/volcengine.ts +++ b/apps/server/src/services/adapters/tts/volcengine.ts @@ -2,7 +2,7 @@ import type { Voice } from 'unspeech' import type { TtsAdapter, TtsAdapterContext, TtsInput, TtsResult, TtsVoiceCatalogContext } from './types' -import { createInternalError } from '../../../utils/error' +import { createBadRequestError, createInternalError } from '../../../utils/error' import { nanoid } from '../../../utils/id' import { listVoicesViaUnSpeech, sendSpeechViaUnSpeech } from './unspeech' @@ -58,6 +58,12 @@ export const volcengineAdapter: TtsAdapter = { : undefined const voice = input.voice ?? DEFAULT_VOLCENGINE_VOICE + if (typeof input.extraOptions?.pitch === 'number' || typeof input.extraOptions?.volume === 'number') { + throw createBadRequestError( + 'volcengine does not support Voice Pack pitch or volume parameters', + 'BAD_REQUEST', + ) + } const encoding = input.responseFormat ?? DEFAULT_VOLCENGINE_FORMAT const speed = input.speed ?? 1 diff --git a/apps/server/src/services/domain/llm-router/concurrency-ledger.ts b/apps/server/src/services/domain/llm-router/concurrency-ledger.ts new file mode 100644 index 000000000..91f7dbbc3 --- /dev/null +++ b/apps/server/src/services/domain/llm-router/concurrency-ledger.ts @@ -0,0 +1,149 @@ +import type Redis from 'ioredis' + +import { + ttsPoolInflightRedisKey, + ttsPoolKnownRedisKey, + ttsPoolSaturatedRedisKey, +} from '../../../utils/redis-keys' + +// NOTICE: Atomic capacity-gated acquire. The TTSpool routes requests across +// multiple app_ids, each capped at a small concurrency limit (e.g. 10). To use +// the pooled capacity without overshooting any single app_id, we track in-flight +// requests per pool in Redis (shared across replicas — the server is multi-instance +// on Railway). A check-then-INCR done in two round-trips would race between +// replicas and overshoot the cap, so the check + increment happen inside one Lua +// script. The EXPIRE bounds leakage: if a replica crashes between acquire and +// release, the counter self-heals after `inflightTtlSeconds` instead of pinning +// the pool as permanently full. Source: flux-meter.ts ACCUMULATE_SCRIPT (same +// "INCR + EXPIRE, TTL survives crash" shape). +const ACQUIRE_SCRIPT = ` +local inflightKey = KEYS[1] +local knownKey = KEYS[2] +local max = tonumber(ARGV[1]) +local ttl = tonumber(ARGV[2]) +local poolId = ARGV[3] + +local current = tonumber(redis.call('GET', inflightKey) or '0') +if current < max then + local next = redis.call('INCR', inflightKey) + redis.call('EXPIRE', inflightKey, ttl) + redis.call('SADD', knownKey, poolId) + return next +end + +return -1 +` + +// NOTICE: Floor-guarded release. A bare DECR on a missing/expired key would +// drive the counter negative (Redis DECR on a nonexistent key yields -1), which +// would then let the pool accept more than `max` concurrent requests. Guarding +// with GET>0 inside Lua keeps release idempotent against the TTL self-heal: if +// the inflight key already expired, release is a no-op rather than a corruption. +const RELEASE_SCRIPT = ` +local inflightKey = KEYS[1] +local current = tonumber(redis.call('GET', inflightKey) or '0') +if current > 0 then + return redis.call('DECR', inflightKey) +end +return 0 +` + +/** + * Tracks per-pool in-flight concurrency in Redis so the TTS router can spread + * load across multiple app_ids without overshooting any one app_id's cap. + * + * Use when: + * - Building the LLM/TTS router service (`createLlmRouterService`), which + * acquires a slot before dispatching to a capacity-capped upstream and + * releases it once the attempt finishes. + * + * Expects: + * - `redis` is the shared cluster Redis (the same instance the flux meter and + * config cache use). Counts are cluster-wide, not per-process. + * + * Returns: + * - An acquire/release/saturation API. `tryAcquire` is the only capacity + * decision; everything else is bookkeeping the router and the watermark + * gauge read. + */ +export function createConcurrencyLedger(redis: Redis, options?: { + /** + * TTL (seconds) on the in-flight counter. Bounds leakage when a replica + * crashes between acquire and release. Should comfortably exceed the longest + * single TTS attempt so a live request is never evicted mid-flight. + * @default 60 + */ + inflightTtlSeconds?: number +}) { + const inflightTtlSeconds = options?.inflightTtlSeconds ?? 60 + const knownKey = ttsPoolKnownRedisKey() + + /** + * Atomically acquire one slot on `poolId` if it is below `maxConcurrency`. + * Returns true when the slot was taken (caller MUST later call `release`), + * false when the pool is already at capacity (caller should try another pool). + */ + async function tryAcquire(poolId: string, maxConcurrency: number): Promise { + const result = await redis.eval( + ACQUIRE_SCRIPT, + 2, + ttsPoolInflightRedisKey(poolId), + knownKey, + maxConcurrency, + inflightTtlSeconds, + poolId, + ) as number | string + return Number(result) >= 0 + } + + /** + * Release one slot previously taken via {@link tryAcquire}. Idempotent and + * floor-guarded — releasing an already-zero/expired counter is a no-op. + */ + async function release(poolId: string): Promise { + await redis.eval(RELEASE_SCRIPT, 1, ttsPoolInflightRedisKey(poolId)) + } + + /** + * Flag `poolId` as saturated for `ttlSeconds`. Called when an upstream + * exhausts with a 429 (app_id concurrency exceeded upstream-side) so the + * router skips this pool during the cool-down instead of re-probing a pool it + * already knows is full. + */ + async function markSaturated(poolId: string, ttlSeconds: number): Promise { + await redis.set(ttsPoolSaturatedRedisKey(poolId), '1', 'EX', ttlSeconds) + } + + /** Whether `poolId` is within a saturation cool-down window. */ + async function isSaturated(poolId: string): Promise { + const exists = await redis.exists(ttsPoolSaturatedRedisKey(poolId)) + return exists === 1 + } + + /** Current in-flight count for `poolId` (0 when the counter is absent). */ + async function currentInflight(poolId: string): Promise { + const raw = await redis.get(ttsPoolInflightRedisKey(poolId)) + return raw == null ? 0 : Number(raw) + } + + /** + * Snapshot every known pool's in-flight count. Backs the watermark gauge — + * reads the known-pools set, then MGETs each counter in one round-trip. + * Returns an empty array when no pool has ever been acquired. + */ + async function snapshot(): Promise> { + const poolIds = await redis.smembers(knownKey) + if (poolIds.length === 0) + return [] + + const values = await redis.mget(poolIds.map(ttsPoolInflightRedisKey)) + return poolIds.map((poolId, i) => ({ + poolId, + inflight: values[i] == null ? 0 : Number(values[i]), + })) + } + + return { tryAcquire, release, markSaturated, isSaturated, currentInflight, snapshot } +} + +export type ConcurrencyLedger = ReturnType diff --git a/apps/server/src/services/domain/llm-router/index.ts b/apps/server/src/services/domain/llm-router/index.ts index 64098d8d8..f5527746f 100644 --- a/apps/server/src/services/domain/llm-router/index.ts +++ b/apps/server/src/services/domain/llm-router/index.ts @@ -1,3 +1,6 @@ +export { createConcurrencyLedger } from './concurrency-ledger' + +export type { ConcurrencyLedger } from './concurrency-ledger' export { createConfigSyncSubscriber } from './config-sync-subscriber' export { createLlmRouterService } from './router' diff --git a/apps/server/src/services/domain/llm-router/router.ts b/apps/server/src/services/domain/llm-router/router.ts index 8112f4d8b..99150dd35 100644 --- a/apps/server/src/services/domain/llm-router/router.ts +++ b/apps/server/src/services/domain/llm-router/router.ts @@ -7,6 +7,7 @@ import type { GatewayMetrics } from '../../../otel' import type { EnvelopeCrypto } from '../../../utils/envelope-crypto' import type { ConfigKVService } from '../../adapters/config-kv' import type { TtsAdapterId, TtsInput } from '../../adapters/tts/types' +import type { ConcurrencyLedger } from './concurrency-ledger' import type { LlmRouteContext, LlmRouteRequest, LlmUpstream, TtsUpstream } from './types' import { Buffer as NodeBuffer } from 'node:buffer' @@ -14,7 +15,7 @@ import { Buffer as NodeBuffer } from 'node:buffer' import { useLogger } from '@guiiai/logg' import { trace } from '@opentelemetry/api' -import { ApiError } from '../../../utils/error' +import { ApiError, createServiceUnavailableError } from '../../../utils/error' import { errorMessageFromUnknown } from '../../../utils/error-message' import { AIRI_ATTR_GEN_AI_GATEWAY_FALLBACK_DEPTH, @@ -91,6 +92,18 @@ function deriveProviderTag(baseURL: string): string { } } +/** + * Identity of the pool (concurrency pool) one TTS upstream belongs to. One + * upstream == one app_id, so the Volcengine `adapterParams.appid` is the pool + * key when present; the baseURL is a stable fallback for providers without an + * app_id concept. Two upstreams sharing an app_id would (correctly) share one + * concurrency budget, though thetypical config gives each app_id its own upstream. + */ +function ttsPoolId(upstream: TtsUpstream): string { + const appid = upstream.adapterParams?.appid + return typeof appid === 'string' && appid.length > 0 ? appid : upstream.baseURL +} + export interface CreateLlmRouterServiceOptions { /** ConfigKV used to read `LLM_ROUTER_CONFIG`. */ configKV: ConfigKVService @@ -104,6 +117,20 @@ export interface CreateLlmRouterServiceOptions { * picker open while keeping freshness within {@link TTS_VOICES_CACHE_TTL_S}. */ redis: Redis + /** + * Per-pool concurrency ledger backing capacity-aware TTS routing. When a TTS + * model has any upstream with `maxConcurrency` set, the router acquires a slot + * here before dispatching and releases it after, spreading load across app_ids + * instead of hammering the first upstream. + */ + concurrencyLedger: ConcurrencyLedger + /** + * Cool-down (seconds) a pool is skipped after exhausting with a 429 (app_id + * concurrency exceeded upstream-side). Separate from the ledger's in-flight + * TTL: this is a reactive circuit-breaker window, not a leak bound. + * @default 15 + */ + ttsPoolSaturationTtlSeconds?: number /** * Fetch implementation. Defaults to `globalThis.fetch`. Tests inject a * `vi.fn` so we never touch the real network. @@ -177,6 +204,8 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { const logger = useLogger('llm-router').useGlobalConfig() const fetchImpl = options.fetchImpl ?? globalThis.fetch const configLoader = createConfigLoader({ configKV: options.configKV, ttlMs: options.configCacheTtlMs }) + const ledger = options.concurrencyLedger + const ttsPoolSaturationTtlSeconds = options.ttsPoolSaturationTtlSeconds ?? 15 const ttsVoiceCatalogLoads = new Map>() /** @@ -542,6 +571,110 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { return { kind: 'exhausted', failures } } + /** + * Capacity-aware layer over {@link dispatchOneTtsUpstream}: spreads one TTS + * request across the model's pool (one app_id per upstream) by least-loaded + * ordering, gating each dispatch on an atomic concurrency-slot acquire. + * + * Returns: + * - the 2xx `Response` on success, + * - `null` when every dispatched upstream exhausted (caller maps the recorded + * failures to an upstream error via the shared exhaustion path), + * - throws 503 `TTS_POOL_SATURATED` when every pool was at capacity or in a + * 429 cool-down so nothing was dispatched - fail-fast with context, never a + * silent stall (origin R3). + */ + async function routeTtsAcrossPools( + upstreams: readonly TtsUpstream[], + modelName: string, + attemptUpstream: (upstream: TtsUpstream, index: number) => Promise< + | { kind: 'ok', response: Response } + | { kind: 'exhausted', sawTooManyRequests: boolean } + >, + ): Promise { + async function markSaturated(upstream: TtsUpstream, poolId: string): Promise { + await ledger.markSaturated(poolId, ttsPoolSaturationTtlSeconds) + options.gatewayMetrics?.poolSaturationMarked.add(1, { + provider: deriveProviderTag(upstream.baseURL), + app_id: poolId, + }) + } + + // Best-effort pre-read: order pools least-loaded-first (spreads load) and + // drop pools already full or in a saturation cool-down. tryAcquire below is + // the authoritative gate against the cross-replica race — ordering only + // decides *preference*, not correctness. + const ranked = (await Promise.all(upstreams.map(async (upstream, index) => { + const poolId = ttsPoolId(upstream) + const maxConcurrency = typeof upstream.maxConcurrency === 'number' ? upstream.maxConcurrency : null + const saturated = await ledger.isSaturated(poolId) + if (saturated) { + return { + upstream, + index, + poolId, + maxConcurrency, + remaining: maxConcurrency == null ? Number.POSITIVE_INFINITY : 0, + eligible: false, + } + } + if (maxConcurrency == null) + return { upstream, index, poolId, maxConcurrency, remaining: Number.POSITIVE_INFINITY, eligible: true } + + const inflight = await ledger.currentInflight(poolId) + const remaining = maxConcurrency - inflight + return { upstream, index, poolId, maxConcurrency, remaining, eligible: remaining > 0 } + }))) + .filter(c => c.eligible) + .sort((a, b) => b.remaining - a.remaining) + + let dispatchedAny = false + for (const { upstream, index, poolId, maxConcurrency } of ranked) { + if (maxConcurrency == null) { + // Unlimited pool — dispatch without occupying a slot. + dispatchedAny = true + const result = await attemptUpstream(upstream, index) + if (result.kind === 'ok') + return result.response + if (result.sawTooManyRequests) + await markSaturated(upstream, poolId) + continue + } + + const acquired = await ledger.tryAcquire(poolId, maxConcurrency) + if (!acquired) { + // Pool filled between the snapshot and now — skip without dispatching. + options.gatewayMetrics?.poolSlotRejected.add(1, { + provider: deriveProviderTag(upstream.baseURL), + app_id: poolId, + }) + continue + } + + dispatchedAny = true + try { + const result = await attemptUpstream(upstream, index) + if (result.kind === 'ok') + return result.response + if (result.sawTooManyRequests) + await markSaturated(upstream, poolId) + } + finally { + await ledger.release(poolId) + } + } + + if (!dispatchedAny) { + throw createServiceUnavailableError( + `ttspool capacity exhausted for model ${modelName}: all pools at concurrency limit or in saturation cool-down`, + 'TTS_POOL_SATURATED', + { modelName, pools: upstreams.length }, + ) + } + + return null + } + async function routeTts(req: { modelName: string, input: TtsInput, abortSignal?: AbortSignal }, ctx?: LlmRouteContext): Promise { if (req.abortSignal?.aborted) throw req.abortSignal.reason ?? new Error('aborted') @@ -553,8 +686,13 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { throw new Error(`Expected tts model slice for ${req.modelName}, got ${slice.kind}`) } + // Capture the narrowed TTS model: the `slice.kind` narrowing above does not + // flow into the nested `attemptUpstream` closure below, so reference this + // local instead of `slice.model` to keep `provider`/`upstreams` typed. + const ttsModel = slice.model + const defaults = slice.defaults ?? { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504] } - const fallbackHttpCodes = slice.model.fallbackTriggers?.httpCodes ?? defaults.fallbackHttpCodes ?? [401, 402, 403, 429, 500, 502, 503, 504] + const fallbackHttpCodes = ttsModel.fallbackTriggers?.httpCodes ?? defaults.fallbackHttpCodes ?? [401, 402, 403, 429, 500, 502, 503, 504] // Adapters POST to unspeech `/v1/audio/speech`; resolve the base URL once // per request rather than per upstream attempt so a single configKV miss @@ -564,23 +702,28 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { const allFailures: Array<{ provider: string, keyId: string, status: number | 'timeout', errorMessage?: string }> = [] let triedUpstreams = 0 - for (let i = 0; i < slice.model.upstreams.length; i += 1) { - const upstream = slice.model.upstreams[i] + // tts upstream schema has no per-upstream timeoutMs (see ttsUpstreamSchema); + // the defaults bucket alone governs per-attempt timeout. + const perAttemptTimeoutMs = defaults.perAttemptTimeoutMs ?? 30000 + + // Dispatch one upstream and fold its outcome into the shared failure log. + // Returns the 2xx Response on success, or an exhaustion marker carrying + // whether the upstream saw a 429 (app_id concurrency exceeded upstream-side) + // so the caller can circuit-break thatpool. + async function attemptUpstream(upstream: TtsUpstream, index: number): Promise< + | { kind: 'ok', response: Response } + | { kind: 'exhausted', sawTooManyRequests: boolean } + > { const providerTag = deriveProviderTag(upstream.baseURL) triedUpstreams += 1 // Surface the current upstream so the caller can label success metrics // by provider (winning provider on `ok`, last-tried on exhaustion). if (ctx) ctx.provider = providerTag - - // tts upstream schema has no per-upstream timeoutMs (see ttsUpstreamSchema); - // we use the defaults bucket alone here. - const perAttemptTimeoutMs = defaults.perAttemptTimeoutMs ?? 30000 - const result = await dispatchOneTtsUpstream( upstream, - i, - slice.model.provider, + index, + ttsModel.provider, req.input, req.modelName, req.abortSignal, @@ -591,13 +734,32 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { ) if (result.kind === 'ok') { - return new Response(result.body, { - status: 200, - headers: { 'content-type': result.contentType }, - }) + return { + kind: 'ok', + response: new Response(result.body, { status: 200, headers: { 'content-type': result.contentType } }), + } } options.gatewayMetrics?.keyExhaustedCount.add(1, { provider: providerTag }) + return { kind: 'exhausted', sawTooManyRequests: result.failures.some(f => f.status === 429) } + } + + // A model "uses the pool" when any upstream declares a concurrency cap. Models + // without one keep the original fixed-order fallback and make zero Redis + // calls — no behavior change for existing single-app configs. + const poolingEnabled = ttsModel.upstreams.some(u => typeof u.maxConcurrency === 'number') + + if (!poolingEnabled) { + for (let i = 0; i < ttsModel.upstreams.length; i += 1) { + const result = await attemptUpstream(ttsModel.upstreams[i], i) + if (result.kind === 'ok') + return result.response + } + } + else { + const served = await routeTtsAcrossPools(ttsModel.upstreams, req.modelName, attemptUpstream) + if (served != null) + return served } const lastFailure = allFailures.at(-1) diff --git a/apps/server/src/services/domain/llm-router/tests/concurrency-ledger.test.ts b/apps/server/src/services/domain/llm-router/tests/concurrency-ledger.test.ts new file mode 100644 index 000000000..72195ea8c --- /dev/null +++ b/apps/server/src/services/domain/llm-router/tests/concurrency-ledger.test.ts @@ -0,0 +1,135 @@ +import type Redis from 'ioredis' + +import { beforeEach, describe, expect, it } from 'vitest' + +import { createConcurrencyLedger } from '../concurrency-ledger' + +// NOTICE: Mimic the subset of Redis semantics the ledger uses (EVAL for the +// ACQUIRE/RELEASE Lua, plus SET/EXISTS/GET/SADD/SMEMBERS/MGET). The two Lua +// scripts are told apart by numKeys (acquire passes 2 keys, release passes 1) — +// same approach flux-meter.test.ts uses for its single script. Real Lua +// atomicity is exercised by ioredis hitting Redis in integration; here we verify +// the capacity decision, floor-guarded release, saturation flags, and snapshot. +function createMockRedis() { + const inflight = new Map() + const saturated = new Set() + const known = new Set() + + const evalImpl = async (_script: string, numKeys: number, ...args: Array) => { + if (numKeys === 2) { + // ACQUIRE_SCRIPT: inflightKey, knownKey, max, ttl, poolId + const inflightKey = String(args[0]) + const knownKey = String(args[1]) + const max = Number(args[2]) + const poolId = String(args[4]) + const current = inflight.get(inflightKey) ?? 0 + if (current < max) { + const next = current + 1 + inflight.set(inflightKey, next) + known.add(`${knownKey}::${poolId}`) + return next + } + return -1 + } + // RELEASE_SCRIPT: inflightKey + const inflightKey = String(args[0]) + const current = inflight.get(inflightKey) ?? 0 + if (current > 0) { + const next = current - 1 + inflight.set(inflightKey, next) + return next + } + return 0 + } + + const redis = { + eval: evalImpl, + set: async (key: string, _val: string, _mode: string, _ttl: number) => { + saturated.add(key) + return 'OK' + }, + exists: async (key: string) => (saturated.has(key) ? 1 : 0), + get: async (key: string) => { + const v = inflight.get(key) + return v == null ? null : String(v) + }, + smembers: async (key: string) => { + const prefix = `${key}::` + return [...known].filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length)) + }, + mget: async (keys: string[]) => keys.map(k => (inflight.has(k) ? String(inflight.get(k)) : null)), + } as unknown as Redis + + return { redis, inflight, saturated } +} + +describe('concurrencyLedger', () => { + let mock: ReturnType + let ledger: ReturnType + + beforeEach(() => { + mock = createMockRedis() + ledger = createConcurrencyLedger(mock.redis) + }) + + it('tryAcquire grants a slot while the pool is below max and increments inflight', async () => { + // @example acquire on an empty pool (cap 10) -> granted, inflight becomes 1 + const granted = await ledger.tryAcquire('app-1', 10) + expect(granted).toBe(true) + expect(await ledger.currentInflight('app-1')).toBe(1) + }) + + it('tryAcquire rejects once the pool is at max without incrementing past the cap', async () => { + // @example cap 2 -> first two granted, third rejected, inflight stays 2 + expect(await ledger.tryAcquire('app-1', 2)).toBe(true) + expect(await ledger.tryAcquire('app-1', 2)).toBe(true) + expect(await ledger.tryAcquire('app-1', 2)).toBe(false) + expect(await ledger.currentInflight('app-1')).toBe(2) + }) + + it('grants no more than max across many acquires on one pool (capacity invariant)', async () => { + // @example cap 10, attempt 15 acquires -> exactly 10 granted + const results = await Promise.all( + Array.from({ length: 15 }, () => ledger.tryAcquire('app-1', 10)), + ) + expect(results.filter(Boolean)).toHaveLength(10) + expect(await ledger.currentInflight('app-1')).toBe(10) + }) + + it('release returns a slot so a previously-full pool can grant again', async () => { + // @example cap 1: acquire, reject second, release, then acquire succeeds + expect(await ledger.tryAcquire('app-1', 1)).toBe(true) + expect(await ledger.tryAcquire('app-1', 1)).toBe(false) + await ledger.release('app-1') + expect(await ledger.currentInflight('app-1')).toBe(0) + expect(await ledger.tryAcquire('app-1', 1)).toBe(true) + }) + + it('release floors at zero and never drives the counter negative', async () => { + // @example releasing an idle pool keeps inflight at 0 (no negative overshoot) + await ledger.release('app-1') + expect(await ledger.currentInflight('app-1')).toBe(0) + }) + + it('isSaturated reflects markSaturated', async () => { + // @example before mark -> false; after mark -> true + expect(await ledger.isSaturated('app-1')).toBe(false) + await ledger.markSaturated('app-1', 5) + expect(await ledger.isSaturated('app-1')).toBe(true) + }) + + it('snapshot lists every acquired pool with its current inflight count', async () => { + // @example acquire on two pools -> snapshot reports both with counts + await ledger.tryAcquire('app-1', 10) + await ledger.tryAcquire('app-1', 10) + await ledger.tryAcquire('app-2', 10) + const snap = await ledger.snapshot() + expect(snap).toContainEqual({ poolId: 'app-1', inflight: 2 }) + expect(snap).toContainEqual({ poolId: 'app-2', inflight: 1 }) + }) + + it('snapshot is empty before any pool is acquired', async () => { + // @example fresh ledger -> snapshot returns [] + expect(await ledger.snapshot()).toEqual([]) + }) +}) diff --git a/apps/server/src/services/domain/llm-router/tests/router.test.ts b/apps/server/src/services/domain/llm-router/tests/router.test.ts index df4f89a47..c9627ef4c 100644 --- a/apps/server/src/services/domain/llm-router/tests/router.test.ts +++ b/apps/server/src/services/domain/llm-router/tests/router.test.ts @@ -5,6 +5,7 @@ import type Redis from 'ioredis' import type { GatewayMetrics } from '../../../../otel' import type { ConfigKVService } from '../../../adapters/config-kv' +import type { ConcurrencyLedger } from '../concurrency-ledger' import type { LlmRouteContext, RouterConfig } from '../types' import { randomBytes } from 'node:crypto' @@ -50,7 +51,27 @@ function makeMetrics(): GatewayMetrics { subscriberState: makeCounter(), configWrite: makeCounter(), configInvalidHmac: makeCounter(), - } as GatewayMetrics + poolSlotRejected: makeCounter(), + poolSaturationMarked: makeCounter(), + poolInflight: { addCallback: vi.fn(), removeCallback: vi.fn() }, + } as unknown as GatewayMetrics +} + +/** + * Stub concurrency ledger. Defaults model an always-free pool (tryAcquire grants, + * nothing saturated) so the existing fixed-order LLM/TTS tests never engage the + * pooling branch. Pooling tests pass `overrides` to drive capacity decisions. + */ +function makeLedger(overrides: Partial = {}): ConcurrencyLedger { + return { + tryAcquire: vi.fn(async () => true), + release: vi.fn(async () => {}), + markSaturated: vi.fn(async () => {}), + isSaturated: vi.fn(async () => false), + currentInflight: vi.fn(async () => 0), + snapshot: vi.fn(async () => []), + ...overrides, + } } function makeConfigKV(config: RouterConfig | null): ConfigKVService { @@ -147,6 +168,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: metrics, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) const res = await router.route({ modelName: 'openai/gpt-5-mini', body: { messages: [] } }) @@ -172,6 +194,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: null, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) const ctx: LlmRouteContext = { provider: 'unknown', triedUpstreams: 0, triedKeys: 0, lastStatus: null } @@ -203,6 +226,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: makeMetrics(), fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) const ctx: LlmRouteContext = { provider: 'unknown', triedUpstreams: 0, triedKeys: 0, lastStatus: null } @@ -221,6 +245,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: null, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) await router.route({ modelName: 'openai/gpt-5-mini', body: { messages: [{ role: 'user', content: 'hi' }] } }) @@ -246,6 +271,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: null, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) const ctx: LlmRouteContext = { provider: 'unknown', triedUpstreams: 0, triedKeys: 0, lastStatus: null } @@ -269,6 +295,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: metrics, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) const res = await router.route({ modelName: 'openai/gpt-5-mini', body: {} }) @@ -303,6 +330,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: metrics, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) const res = await router.route({ modelName: 'openai/gpt-5-mini', body: {} }) @@ -329,6 +357,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: metrics, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) try { @@ -374,6 +403,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: null, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) try { @@ -419,6 +449,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: metrics, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) await expect(router.route({ modelName: 'openai/gpt-5-mini', body: {} })).rejects.toMatchObject({ statusCode: 503, errorCode: 'SERVICE_UNAVAILABLE' }) @@ -448,6 +479,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: null, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) try { @@ -494,6 +526,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: null, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) const res = await router.route({ modelName: 'openai/gpt-5-mini', body: {} }) @@ -524,6 +557,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: null, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) try { @@ -549,6 +583,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: metrics, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) try { @@ -574,6 +609,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: null, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) await expect(router.route({ modelName: 'whatever', body: {} })).rejects.toMatchObject({ statusCode: 503, errorCode: 'CONFIG_NOT_SET' }) @@ -592,6 +628,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: null, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) await expect(router.route({ modelName: 'openai/gpt-5-mini', body: {}, abortSignal: ctrl.signal })).rejects.toThrow(/client-disconnected/) @@ -623,6 +660,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: null, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) await expect(router.route({ modelName: 'openai/gpt-5-mini', body: {}, abortSignal: ctrl.signal })).rejects.toThrow(/client-disconnected/) @@ -641,6 +679,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: null, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) await router.route({ modelName: 'openai/gpt-5-mini', body: {} }) @@ -717,6 +756,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: metrics, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) let caught: unknown @@ -760,6 +800,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: metrics, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) const res = await router.routeTts({ @@ -795,6 +836,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: metrics, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) const res = await router.routeTts({ @@ -840,6 +882,7 @@ describe('createLlmRouterService', () => { gatewayMetrics: null, fetchImpl, redis: makeRedisStub(), + concurrencyLedger: makeLedger(), }) const first = router.listTtsVoices('tts-test') @@ -855,4 +898,242 @@ describe('createLlmRouterService', () => { expect(secondVoices.map(voice => voice.id)).toEqual(['en-US-AvaMultilingualNeural']) }) }) + + describe('routeTtspool capacity-aware routing', () => { + // One app_id == one upstream (Volcengine `adapterParams.appid`), each capped + // at `maxConcurrency`. The router spreads load least-loaded-first across pools + // and circuit-breaks a pool on 429 (app_id concurrency exceeded upstream-side). + function makePoolConfig( + upstreams: Array<{ baseURL: string, appid: string, maxConcurrency?: number }>, + ): { config: RouterConfig, crypto: ReturnType } { + const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() }) + const modelName = 'tts-pool' + const upstreamConfigs = upstreams.map((u, i) => { + const id = `k${i}` + const ct = crypto.encryptKey(`sk-${id}`, { modelName, keyEntryId: id }) + return { + baseURL: u.baseURL, + keys: [{ id, ciphertext: ct }], + adapterParams: { appid: u.appid }, + ...(u.maxConcurrency != null ? { maxConcurrency: u.maxConcurrency } : {}), + } + }) + const config = { + llm: { models: {} }, + tts: { + models: { + [modelName]: { + provider: 'volcengine', + upstreams: upstreamConfigs, + fallbackTriggers: { httpCodes: [401, 429, 500, 502, 503, 504], onTimeout: true }, + }, + }, + }, + defaults: { perAttemptTimeoutMs: 5000, fullChainTimeoutMs: 10000, fallbackHttpCodes: [401, 429, 500, 502, 503, 504] }, + } as RouterConfig + return { config, crypto } + } + + // Stateful in-memory ledger so least-loaded ordering and capacity gating are + // observable. `seed` pre-loads inflight counts to drive deterministic ranking. + function makeStatefulLedger(seed: Record = {}, saturatedSeed: string[] = []) { + const inflight = new Map(Object.entries(seed)) + const saturated = new Set(saturatedSeed) + const tryAcquire = vi.fn(async (poolId: string, max: number) => { + const cur = inflight.get(poolId) ?? 0 + if (saturated.has(poolId) || cur >= max) + return false + inflight.set(poolId, cur + 1) + return true + }) + const release = vi.fn(async (poolId: string) => { + inflight.set(poolId, Math.max(0, (inflight.get(poolId) ?? 0) - 1)) + }) + const markSaturated = vi.fn(async (poolId: string) => { + saturated.add(poolId) + }) + const ledger: ConcurrencyLedger = { + tryAcquire, + release, + markSaturated, + isSaturated: vi.fn(async (poolId: string) => saturated.has(poolId)), + currentInflight: vi.fn(async (poolId: string) => inflight.get(poolId) ?? 0), + snapshot: vi.fn(async () => [...inflight].map(([poolId, n]) => ({ poolId, inflight: n }))), + } + return { ledger, inflight, saturated, tryAcquire, release, markSaturated } + } + + function makePoolRouter(config: RouterConfig, crypto: ReturnType, ledger: ConcurrencyLedger, fetchImpl: typeof fetch) { + return createLlmRouterService({ + configKV: makeConfigKV(config), + envelopeCrypto: crypto, + gatewayMetrics: makeMetrics(), + fetchImpl, + redis: makeRedisStub(), + concurrencyLedger: ledger, + }) + } + + it('routes to the least-loadedpool (covers AE1 — load spread, not first-fill)', async () => { + // @example two app_ids cap 10, seeded 8 vs 2 in-flight -> the new request + // goes to the freer pool (app-2), not the config-first pool (app-1). + const { config, crypto } = makePoolConfig([ + { baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 }, + { baseURL: 'https://up-b.example', appid: 'app-2', maxConcurrency: 10 }, + ]) + const { ledger, tryAcquire } = makeStatefulLedger({ 'app-1': 8, 'app-2': 2 }) + const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + const res = await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } }) + + expect(res.status).toBe(200) + expect(tryAcquire).toHaveBeenCalledTimes(1) + expect(tryAcquire.mock.calls[0][0]).toBe('app-2') + }) + + it('skips a fullpool and dispatches to one with capacity', async () => { + // @example app-1 at cap (10/10) -> filtered out; app-2 (0/10) serves. + const { config, crypto } = makePoolConfig([ + { baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 }, + { baseURL: 'https://up-b.example', appid: 'app-2', maxConcurrency: 10 }, + ]) + const { ledger, tryAcquire } = makeStatefulLedger({ 'app-1': 10, 'app-2': 0 }) + const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + const res = await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } }) + + expect(res.status).toBe(200) + expect(tryAcquire.mock.calls.every(([poolId]) => poolId !== 'app-1')).toBe(true) + expect(tryAcquire.mock.calls.some(([poolId]) => poolId === 'app-2')).toBe(true) + }) + + it('fails fast with 503 TTS_POOL_SATURATED when everypool is full (covers AE2 — no silent stall)', async () => { + // @example both app_ids at cap -> 503, upstream is never dispatched. + const { config, crypto } = makePoolConfig([ + { baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 }, + { baseURL: 'https://up-b.example', appid: 'app-2', maxConcurrency: 10 }, + ]) + const { ledger } = makeStatefulLedger({ 'app-1': 10, 'app-2': 10 }) + const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + let caught: unknown + try { + await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } }) + } + catch (err) { + caught = err + } + + expect(caught).toBeInstanceOf(ApiError) + expect((caught as ApiError).statusCode).toBe(503) + expect((caught as ApiError).errorCode).toBe('TTS_POOL_SATURATED') + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('releases the slot after a successful dispatch', async () => { + // @example acquire then release leaves the pool's inflight back at baseline. + const { config, crypto } = makePoolConfig([ + { baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 }, + ]) + const { ledger, release, inflight } = makeStatefulLedger({ 'app-1': 3 }) + const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } }) + + expect(release).toHaveBeenCalledWith('app-1') + expect(inflight.get('app-1')).toBe(3) + }) + + it('makes zero ledger calls when no upstream declares maxConcurrency (no regression)', async () => { + // @example a model without any concurrency cap keeps the original + // fixed-order path and never touches Redis. + const { config, crypto } = makePoolConfig([ + { baseURL: 'https://up-a.example', appid: 'app-1' }, + ]) + const { ledger, tryAcquire } = makeStatefulLedger() + const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + const res = await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } }) + + expect(res.status).toBe(200) + expect(tryAcquire).not.toHaveBeenCalled() + }) + + it('marks a pool saturated when it exhausts with a 429 (covers AE3 — bad-pool circuit break)', async () => { + // @example single pool returns 429 (app_id concurrency exceeded) -> the + // pool is circuit-broken so later requests skip it during the cool-down. + const { config, crypto } = makePoolConfig([ + { baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 }, + ]) + const { ledger, markSaturated } = makeStatefulLedger() + const fetchImpl = vi.fn(async () => failResponse(429)) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + await expect(router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } })).rejects.toBeInstanceOf(ApiError) + + expect(markSaturated).toHaveBeenCalledWith('app-1', expect.any(Number)) + }) + + it('does NOT mark saturated when a pool exhausts with a non-429 status', async () => { + // @example a 500 is a server error, not a concurrency signal — the pool + // must stay eligible rather than being circuit-broken. + const { config, crypto } = makePoolConfig([ + { baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 }, + ]) + const { ledger, markSaturated } = makeStatefulLedger() + const fetchImpl = vi.fn(async () => failResponse(500)) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + await expect(router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } })).rejects.toBeInstanceOf(ApiError) + + expect(markSaturated).not.toHaveBeenCalled() + }) + + it('skips a pool already in a saturation cool-down', async () => { + // @example app-1 flagged saturated -> filtered out; app-2 serves. + const { config, crypto } = makePoolConfig([ + { baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 }, + { baseURL: 'https://up-b.example', appid: 'app-2', maxConcurrency: 10 }, + ]) + const { ledger, tryAcquire } = makeStatefulLedger({}, ['app-1']) + const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + const res = await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } }) + + expect(res.status).toBe(200) + expect(tryAcquire.mock.calls.every(([poolId]) => poolId !== 'app-1')).toBe(true) + expect(tryAcquire.mock.calls.some(([poolId]) => poolId === 'app-2')).toBe(true) + }) + + it('skips an uncapped pool already in a saturation cool-down when another pool is capped', async () => { + // ROOT CAUSE: + // + // Before the fix, the capacity-aware branch returned uncapped pools as + // always eligible without reading the saturation flag. In mixed configs + // (`app-1` uncapped, `app-2` capped), a 429-saturated uncapped app stayed + // first because it had infinite remaining capacity. + // + // We fixed this by checking cooldown state before the capped/uncapped + // branch so both pool shapes honor the same circuit breaker. + const { config, crypto } = makePoolConfig([ + { baseURL: 'https://up-a.example', appid: 'app-1' }, + { baseURL: 'https://up-b.example', appid: 'app-2', maxConcurrency: 10 }, + ]) + const { ledger, tryAcquire } = makeStatefulLedger({}, ['app-1']) + const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + const res = await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } }) + + expect(res.status).toBe(200) + expect(tryAcquire).toHaveBeenCalledTimes(1) + expect(tryAcquire).toHaveBeenCalledWith('app-2', 10) + }) + }) }) diff --git a/apps/server/src/services/domain/openai-speech/index.ts b/apps/server/src/services/domain/openai-speech/index.ts index 1fdbf56ab..193fb0bad 100644 --- a/apps/server/src/services/domain/openai-speech/index.ts +++ b/apps/server/src/services/domain/openai-speech/index.ts @@ -6,11 +6,12 @@ import type { LlmRouterService } from '../llm-router' import type { startTtsGeneration, TtsGenerationTrace } from '../llm-tracing' import type { ProductEventService } from '../product-events' import type { RequestLogService } from '../request-log' +import type { VoicePackService } from '../voice-packs' import { useLogger } from '@guiiai/logg' import { context, SpanStatusCode, trace } from '@opentelemetry/api' -import { createPaymentRequiredError } from '../../../utils/error' +import { ApiError, createBadRequestError, createPaymentRequiredError } from '../../../utils/error' import { nanoid } from '../../../utils/id' import { AIRI_ATTR_BILLING_FLUX_CONSUMED, @@ -27,12 +28,26 @@ const SAFE_RESPONSE_HEADERS = new Set([ 'cache-control', ]) +function asRecord(value: unknown): Record | undefined { + if (typeof value !== 'object' || value == null || Array.isArray(value)) + return undefined + return value as Record +} + +function readOptionalNumber(record: Record | undefined, key: string): number | undefined { + const value = record?.[key] + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined +} + export interface OpenAiSpeechServiceDeps { fluxService: FluxService configKV: ConfigKVService requestLogService: RequestLogService ttsMeter: FluxMeter llmRouter: LlmRouterService + voicePackService: VoicePackService productEventService: ProductEventService genAi?: GenAiMetrics | null llmTracing: { @@ -72,6 +87,13 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { if (requestModel === 'auto') requestModel = await deps.configKV.getOrThrow('DEFAULT_TTS_MODEL') + const voicePackRequest = await voicePackRequestOptions(input.body, { + model: requestModel, + voice: typeof input.body.voice === 'string' ? input.body.voice : undefined, + voicePackService: deps.voicePackService, + }) + const billingUnits = Math.ceil(inputText.length * voicePackRequest.costMultiplier) + logger.withFields({ requestId, userId: input.userId, @@ -95,13 +117,14 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { const flux = await deps.fluxService.getFlux(input.userId) if (flux.flux <= 0) throw createPaymentRequiredError('Insufficient flux') - await deps.ttsMeter.assertCanAfford(input.userId, inputText.length, flux.flux) + await deps.ttsMeter.assertCanAfford(input.userId, billingUnits, flux.flux) const ttsInput = { text: inputText, voice: typeof input.body.voice === 'string' ? input.body.voice : undefined, speed: typeof input.body.speed === 'number' ? input.body.speed : undefined, responseFormat: typeof input.body.response_format === 'string' ? input.body.response_format : undefined, + extraOptions: voicePackRequest.extraOptions, } const generationTrace = deps.llmTracing.startTtsGeneration({ @@ -131,15 +154,16 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { }, routeCtx)) } catch (err) { - span.setStatus({ code: SpanStatusCode.ERROR, message: 'TTS router exhausted or unknown model' }) + const failure = routerFailure(err) + span.setStatus({ code: SpanStatusCode.ERROR, message: failure.message }) span.end() - generationTrace.fail('TTS router exhausted or unknown model') + generationTrace.fail(failure.message) recordMetrics({ durationMs: Date.now() - startedAt, fluxConsumed: 0, model: requestModel, provider: routeCtx.provider, - status: 502, + status: failure.status, }) void deps.productEventService.track({ userId: input.userId, @@ -149,8 +173,9 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { source: 'audio.speech', model: requestModel, provider: routeCtx.provider, - reason: 'router_exhausted', + reason: failure.reason, metadata: { + http_status: failure.status, duration_ms: Date.now() - startedAt, }, }) @@ -191,10 +216,10 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { try { const result = await deps.ttsMeter.accumulate({ userId: input.userId, - units: inputText.length, + units: billingUnits, currentBalance: flux.flux, requestId, - metadata: { model: requestModel }, + metadata: { model: requestModel, costMultiplier: voicePackRequest.costMultiplier }, }) fluxConsumed = result.fluxDebited span.setAttribute(AIRI_ATTR_BILLING_FLUX_CONSUMED, fluxConsumed) @@ -224,6 +249,8 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { metadata: { http_status: response.status, input_chars: inputText.length, + billing_units: billingUnits, + cost_multiplier: voicePackRequest.costMultiplier, duration_ms: durationMs, flux_consumed: fluxConsumed, }, @@ -273,6 +300,78 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { return { handleSpeechRequest } } +async function voicePackRequestOptions( + body: Record, + context: { + model: string + voice?: string + voicePackService: VoicePackService + }, +): Promise<{ extraOptions: Record | undefined, costMultiplier: number }> { + const extraBody = asRecord(body.extra_body) + const voicePackOptions = asRecord(extraBody?.voice_pack) + const pitch = readOptionalNumber(voicePackOptions, 'pitch') + const volume = readOptionalNumber(voicePackOptions, 'volume') + const costMultiplier = await resolveVoicePackCostMultiplier(voicePackOptions, context) + const extraOptions: Record = {} + if (pitch != null) + extraOptions.pitch = pitch + if (volume != null) + extraOptions.volume = volume + + return { + extraOptions: Object.keys(extraOptions).length > 0 ? extraOptions : undefined, + costMultiplier, + } +} + +async function resolveVoicePackCostMultiplier( + voicePackOptions: Record | undefined, + context: { + model: string + voice?: string + voicePackService: VoicePackService + }, +): Promise { + const packId = voicePackOptions?.pack_id + const value = voicePackOptions?.cost_multiplier + if (packId == null && value == null) + return 1 + if (typeof packId !== 'string' || !packId.trim()) + throw createBadRequestError('voice_pack.pack_id is required when Voice Pack billing metadata is provided', 'INVALID_VOICE_PACK') + + const pack = await context.voicePackService.findById(packId) + if (!pack) + throw createBadRequestError('Voice Pack not found', 'INVALID_VOICE_PACK', { packId }) + if (pack.ttsModelId !== context.model || pack.voiceId !== context.voice) { + throw createBadRequestError('Voice Pack does not match requested model and voice', 'INVALID_VOICE_PACK', { + packId, + expectedModel: pack.ttsModelId, + actualModel: context.model, + expectedVoice: pack.voiceId, + actualVoice: context.voice, + }) + } + + return pack.costMultiplier +} + +function routerFailure(error: unknown): { status: number, reason: string, message: string } { + if (error instanceof ApiError) { + return { + status: error.statusCode, + reason: error.errorCode, + message: error.message, + } + } + + return { + status: 502, + reason: 'router_exhausted', + message: 'TTS router exhausted or unknown model', + } +} + function buildSafeResponseHeaders(response: Response): Headers { const headers = new Headers() response.headers.forEach((value, key) => { diff --git a/apps/server/src/services/domain/product-events.ts b/apps/server/src/services/domain/product-events.ts index 74cc3adb8..3d836a9ba 100644 --- a/apps/server/src/services/domain/product-events.ts +++ b/apps/server/src/services/domain/product-events.ts @@ -9,7 +9,7 @@ import * as schema from '../../schemas/product-events' const logger = useLogger('product-events') -export type ProductFeature = 'auth' | 'chat' | 'gen_ai_chat' | 'tts' | 'billing' +export type ProductFeature = 'auth' | 'chat' | 'gen_ai_chat' | 'tts' | 'billing' | 'voice_pack' export type ProductEventStatus = 'started' | 'succeeded' | 'failed' @@ -23,6 +23,9 @@ export type ProductAction | 'speech_requested' | 'speech_succeeded' | 'speech_failed' + | 'voice_pack_created' + | 'voice_pack_updated' + | 'voice_pack_disabled' | 'checkout_started' | 'payment_completed' diff --git a/apps/server/src/services/domain/voice-packs/index.test.ts b/apps/server/src/services/domain/voice-packs/index.test.ts new file mode 100644 index 000000000..97ed4acd0 --- /dev/null +++ b/apps/server/src/services/domain/voice-packs/index.test.ts @@ -0,0 +1,126 @@ +import type { Database } from '../../../libs/db' + +import { beforeAll, beforeEach, describe, expect, it } from 'vitest' + +import { createVoicePackService } from '.' +import { mockDB } from '../../../libs/mock-db' + +import * as schema from '../../../schemas' + +describe('voicePackService', () => { + let db: Database + let service: ReturnType + + beforeAll(async () => { + db = await mockDB(schema) + service = createVoicePackService(db) + }) + + beforeEach(async () => { + await db.delete(schema.voicePacks) + }) + + it('creates a Voice Pack with provider, model, voice, params, cost multiplier, and tts model pin', async () => { + // @example create one curated cloud voice -> row stores the resolved routing pin. + const pack = await service.create({ + name: 'Neuro Sama', + provider: 'volcengine', + model: 'seed-tts-2.0', + voiceId: 'voice-neuro', + ttsModelId: 'volcengine/neuro-pool', + params: { pitch: '+20%', volume: '+5%' }, + costMultiplier: 1.5, + enabled: true, + }) + + expect(pack.name).toBe('Neuro Sama') + expect(pack.provider).toBe('volcengine') + expect(pack.model).toBe('seed-tts-2.0') + expect(pack.voiceId).toBe('voice-neuro') + expect(pack.ttsModelId).toBe('volcengine/neuro-pool') + expect(pack.params).toEqual({ pitch: '+20%', volume: '+5%' }) + expect(pack.costMultiplier).toBe(1.5) + expect(pack.enabled).toBe(true) + }) + + it('keeps parameter variants as separate packs', async () => { + // @example same provider/model/voice with different params -> two library entries. + await service.create({ + name: 'Base', + provider: 'volcengine', + model: 'seed-tts-2.0', + voiceId: 'voice-a', + ttsModelId: 'volcengine/pool', + params: {}, + costMultiplier: 1, + enabled: true, + }) + await service.create({ + name: 'Pitched', + provider: 'volcengine', + model: 'seed-tts-2.0', + voiceId: 'voice-a', + ttsModelId: 'volcengine/pool', + params: { pitch: '+20%' }, + costMultiplier: 1, + enabled: true, + }) + + const packs = await service.list() + expect(packs).toHaveLength(2) + expect(packs.map(p => p.name).sort()).toEqual(['Base', 'Pitched']) + }) + + it('updates mutable fields without replacing the row', async () => { + // @example edit curation metadata/params -> same id, updated values. + const pack = await service.create({ + name: 'Old', + provider: 'azure', + model: 'v1', + voiceId: 'en-US-AvaMultilingualNeural', + ttsModelId: 'microsoft/v1', + params: {}, + costMultiplier: 1, + enabled: true, + }) + + const updated = await service.update(pack.id, { + name: 'New', + params: { rate: '+10%' }, + costMultiplier: 2, + }) + + expect(updated?.id).toBe(pack.id) + expect(updated?.name).toBe('New') + expect(updated?.params).toEqual({ rate: '+10%' }) + expect(updated?.costMultiplier).toBe(2) + }) + + it('soft-disables a pack and excludes it from listEnabled', async () => { + // @example disabled packs remain in admin list but disappear from user list. + const pack = await service.create({ + name: 'Disable me', + provider: 'dashscope-cosyvoice', + model: 'cosyvoice-v2', + voiceId: 'longxiaochun_v2', + ttsModelId: 'alibaba/cosyvoice-v2', + params: {}, + costMultiplier: 1, + enabled: true, + }) + + const disabled = await service.disable(pack.id) + const all = await service.list() + const enabled = await service.listEnabled() + + expect(disabled?.enabled).toBe(false) + expect(all).toHaveLength(1) + expect(enabled).toEqual([]) + }) + + it('returns null when updating or disabling a missing pack', async () => { + // @example unknown id -> null so routes can map to 404. + expect(await service.update('missing', { name: 'Nope' })).toBeNull() + expect(await service.disable('missing')).toBeNull() + }) +}) diff --git a/apps/server/src/services/domain/voice-packs/index.ts b/apps/server/src/services/domain/voice-packs/index.ts new file mode 100644 index 000000000..bab712477 --- /dev/null +++ b/apps/server/src/services/domain/voice-packs/index.ts @@ -0,0 +1,128 @@ +import type { InferOutput } from 'valibot' + +import type { Database } from '../../../libs/db' +import type { VoicePack } from '../../../schemas/voice-packs' + +import { and, eq } from 'drizzle-orm' +import { boolean, maxLength, minValue, nonEmpty, null_, number, object, optional, pipe, record, string, union } from 'valibot' + +import * as schema from '../../../schemas/voice-packs' + +export const VoicePackParamsSchema = record( + pipe(string(), nonEmpty('params keys must not be empty'), maxLength(100)), + union([string(), number(), boolean(), null_()]), +) + +export const VoicePackCostMultiplierSchema = pipe( + number(), + minValue(0, 'costMultiplier must not be negative'), +) + +export const CreateVoicePackInputSchema = object({ + name: pipe(string(), nonEmpty('name is required'), maxLength(120)), + description: optional(pipe(string(), maxLength(500))), + provider: pipe(string(), nonEmpty('provider is required'), maxLength(100)), + model: pipe(string(), nonEmpty('model is required'), maxLength(200)), + voiceId: pipe(string(), nonEmpty('voiceId is required'), maxLength(200)), + ttsModelId: pipe(string(), nonEmpty('ttsModelId is required'), maxLength(200)), + params: optional(VoicePackParamsSchema, {}), + costMultiplier: VoicePackCostMultiplierSchema, + enabled: optional(boolean(), true), +}) + +export const UpdateVoicePackInputSchema = object({ + name: optional(pipe(string(), nonEmpty('name must not be empty'), maxLength(120))), + description: optional(pipe(string(), maxLength(500))), + provider: optional(pipe(string(), nonEmpty('provider must not be empty'), maxLength(100))), + model: optional(pipe(string(), nonEmpty('model must not be empty'), maxLength(200))), + voiceId: optional(pipe(string(), nonEmpty('voiceId must not be empty'), maxLength(200))), + ttsModelId: optional(pipe(string(), nonEmpty('ttsModelId must not be empty'), maxLength(200))), + params: optional(VoicePackParamsSchema), + costMultiplier: optional(VoicePackCostMultiplierSchema), + enabled: optional(boolean()), +}) + +/** + * Voice Pack creation input accepted by the admin service. + */ +export type CreateVoicePackInput = InferOutput + +/** + * Voice Pack update input accepted by the admin service. + */ +export type UpdateVoicePackInput = InferOutput + +/** + * Handles the curated server-side Voice Pack library. + * + * Use when: + * - Admin routes create, update, disable, or list curated cloud-provider voices. + * - Client routes need the enabled-only market list for binding. + * + * Expects: + * - HTTP routes validate input with the exported Valibot schemas before calling. + * + * Returns: + * - CRUD methods that preserve rows and use `enabled=false` as soft disable. + */ +export function createVoicePackService(db: Database) { + return { + async create(input: CreateVoicePackInput) { + const [inserted] = await db.insert(schema.voicePacks).values({ + name: input.name, + description: input.description, + provider: input.provider, + model: input.model, + voiceId: input.voiceId, + ttsModelId: input.ttsModelId, + params: input.params, + costMultiplier: input.costMultiplier, + enabled: input.enabled, + }).returning() + + return inserted + }, + + async list() { + return await db.query.voicePacks.findMany({ + orderBy: (voicePacks, { desc }) => [desc(voicePacks.createdAt)], + }) + }, + + async listEnabled() { + return await db.query.voicePacks.findMany({ + where: eq(schema.voicePacks.enabled, true), + orderBy: (voicePacks, { desc }) => [desc(voicePacks.createdAt)], + }) + }, + + async findById(id: string) { + return await db.query.voicePacks.findFirst({ + where: eq(schema.voicePacks.id, id), + }) + }, + + async update(id: string, input: UpdateVoicePackInput): Promise { + const [updated] = await db.update(schema.voicePacks) + .set({ ...input, updatedAt: new Date() }) + .where(eq(schema.voicePacks.id, id)) + .returning() + + return updated ?? null + }, + + async disable(id: string): Promise { + const [updated] = await db.update(schema.voicePacks) + .set({ enabled: false, updatedAt: new Date() }) + .where(and( + eq(schema.voicePacks.id, id), + eq(schema.voicePacks.enabled, true), + )) + .returning() + + return updated ?? null + }, + } +} + +export type VoicePackService = ReturnType diff --git a/apps/server/src/utils/observability.ts b/apps/server/src/utils/observability.ts index 7db85b95a..a2ce26ba6 100644 --- a/apps/server/src/utils/observability.ts +++ b/apps/server/src/utils/observability.ts @@ -150,6 +150,18 @@ export const METRIC_AIRI_GEN_AI_GATEWAY_DECRYPT_FAILURES = 'airi.gen_ai.gateway. export const METRIC_AIRI_GEN_AI_GATEWAY_SUBSCRIBER_STATE = 'airi.gen_ai.gateway.subscriber_state' export const METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_WRITE = 'airi.gen_ai.gateway.config.write' export const METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_INVALID_HMAC = 'airi.gen_ai.gateway.config.invalid_hmac' +// TTSpool (per app_id concurrency pool) load-balancer signals. +// pool_slot_rejected — capacity-aware routing skipped a pool because its app_id +// was already at the concurrency cap (labels: provider, app_id). +// pool_saturation_marked +// — a pool was circuit-broken after exhausting with a 429 +// (labels: provider, app_id). +// pool_inflight — cluster-wide gauge of current in-flight requests per pool, +// sourced from Redis (label: app_id). Dashboard must avg(), +// not sum() — every replica reports the same value. +export const METRIC_AIRI_GEN_AI_GATEWAY_POOL_SLOT_REJECTED = 'airi.gen_ai.gateway.pool.slot_rejected' +export const METRIC_AIRI_GEN_AI_GATEWAY_POOL_SATURATION_MARKED = 'airi.gen_ai.gateway.pool.saturation_marked' +export const METRIC_AIRI_GEN_AI_GATEWAY_POOL_INFLIGHT = 'airi.gen_ai.gateway.pool.inflight' // --------------------------------------------------------------------------- // Canonical gen_ai.system values diff --git a/apps/server/src/utils/redis-keys.ts b/apps/server/src/utils/redis-keys.ts index 74fa47dfe..2a6db2679 100644 --- a/apps/server/src/utils/redis-keys.ts +++ b/apps/server/src/utils/redis-keys.ts @@ -32,3 +32,31 @@ export function userChatBroadcastRedisKey(userId: string): string { export function lockRedisKey(domain: string, ...identifiers: RedisKeyPart[]): string { return redisKeyFrom('lock', domain, ...identifiers) } + +/** + * In-flight request counter for one TTSpool (per app_id concurrency pool). + * `poolId` is the upstream's `adapterParams.appid` (or baseURL fallback). The + * counter is INCR'd on slot acquire and DECR'd on release; a short TTL bounds + * leakage if a replica crashes between acquire and release. + */ +export function ttsPoolInflightRedisKey(poolId: string): string { + return redisKeyFrom('tts', 'pool', 'inflight', poolId) +} + +/** + * Short-TTL saturation flag for one TTSpool. Set when an upstream exhausts with + * a 429 (app_id concurrency exceeded) so capacity-aware routing skips that pool + * for a cool-down window instead of repeatedly hammering a known-full pool. + */ +export function ttsPoolSaturatedRedisKey(poolId: string): string { + return redisKeyFrom('tts', 'pool', 'saturated', poolId) +} + +/** + * Set of everypool id the router has acquired a slot on. The pool watermark + * gauge reads this set's members, then MGETs each inflight counter — avoids + * parsing LLM_ROUTER_CONFIG inside the metric callback. + */ +export function ttsPoolKnownRedisKey(): string { + return redisKeyFrom('tts', 'pool', 'known') +} diff --git a/apps/ui-admin/README.md b/apps/ui-admin/README.md new file mode 100644 index 000000000..26362fdeb --- /dev/null +++ b/apps/ui-admin/README.md @@ -0,0 +1,25 @@ +# AIRI Admin Dashboard + +Admin dashboard for operating the hosted AIRI server. It is a Vue/Vite app built into `apps/server/public/ui-admin` and served by the server at `/admin`. + +## Use When + +- Reviewing server metrics, users, flux balances, LLM router config, and curated Voice Packs. +- Building operator-only workflows that depend on the server admin API under `/api/admin`. + +## Do Not Use When + +- Building end-user settings or character-card flows. Those belong in the stage apps and shared stage packages. +- Adding unauthenticated server UI. This app expects the server admin guard and Better Auth session cookies. + +## Commands + +```sh +pnpm -F @proj-airi/ui-admin dev +pnpm -F @proj-airi/ui-admin typecheck +pnpm -F @proj-airi/ui-admin build +``` + +## Build Output + +`pnpm -F @proj-airi/ui-admin build` writes to `apps/server/public/ui-admin`. Build this app before running a server image or local server flow that needs `/admin` to serve real HTML instead of reporting a missing admin UI artifact. diff --git a/apps/ui-admin/src/App.vue b/apps/ui-admin/src/App.vue index a5444314e..288ea360d 100644 --- a/apps/ui-admin/src/App.vue +++ b/apps/ui-admin/src/App.vue @@ -19,6 +19,7 @@ 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: '/voice-packs', icon: 'i-lucide-volume-2', label: 'Voice Packs' }, ] const currentTitle = computed(() => navItems.find(item => item.to === route.path)?.label ?? 'Overview') diff --git a/apps/ui-admin/src/main.ts b/apps/ui-admin/src/main.ts index 79df7e579..720d7bf5a 100644 --- a/apps/ui-admin/src/main.ts +++ b/apps/ui-admin/src/main.ts @@ -10,6 +10,7 @@ import FluxPage from './pages/FluxPage.vue' import LlmRouterPage from './pages/LlmRouterPage.vue' import OverviewPage from './pages/OverviewPage.vue' import UsersPage from './pages/UsersPage.vue' +import VoicePacksPage from './pages/VoicePacksPage.vue' import '@proj-airi/font-chillroundm/index.css' import '@unocss/reset/tailwind.css' @@ -24,6 +25,7 @@ const router = createRouter({ { path: '/users', component: UsersPage }, { path: '/flux', component: FluxPage }, { path: '/llm-router', component: LlmRouterPage }, + { path: '/voice-packs', component: VoicePacksPage }, ], }) diff --git a/apps/ui-admin/src/modules/api.ts b/apps/ui-admin/src/modules/api.ts index 95448cd5f..2fe8b81ab 100644 --- a/apps/ui-admin/src/modules/api.ts +++ b/apps/ui-admin/src/modules/api.ts @@ -64,6 +64,37 @@ export interface AdminRouterConfigResult { preview: Record } +export interface VoicePackParams { + [key: string]: string | number | boolean | null +} + +export interface VoicePack { + id: string + name: string + description: string | null + provider: string + model: string + voiceId: string + ttsModelId: string + params: VoicePackParams + costMultiplier: number + enabled: boolean + createdAt: string + updatedAt: string +} + +export interface VoicePackPayload { + name: string + description?: string + provider: string + model: string + voiceId: string + ttsModelId: string + params?: VoicePackParams + costMultiplier: number + enabled?: boolean +} + export class AdminApiError extends Error { constructor( message: string, @@ -171,4 +202,19 @@ export const adminApi = { method: 'POST', body: JSON.stringify({ ...body, dryRun }), }), + voicePacks: () => adminFetch('/voice-packs'), + createVoicePack: (body: VoicePackPayload) => + adminFetch('/voice-packs', { + method: 'POST', + body: JSON.stringify(body), + }), + updateVoicePack: (id: string, body: Partial) => + adminFetch(`/voice-packs/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), + disableVoicePack: (id: string) => + adminFetch(`/voice-packs/${encodeURIComponent(id)}/disable`, { + method: 'POST', + }), } diff --git a/apps/ui-admin/src/pages/VoicePacksPage.vue b/apps/ui-admin/src/pages/VoicePacksPage.vue new file mode 100644 index 000000000..0f0955b6b --- /dev/null +++ b/apps/ui-admin/src/pages/VoicePacksPage.vue @@ -0,0 +1,371 @@ + + +