From 1a552e7b6a0a6ab9e76b142affac4a1e4653b571 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 23:50:55 +0000 Subject: [PATCH 01/14] feat(ai): shared AI action registry, runner, accounting and budgets Plugins declare AI actions with defineAiAction; Core resolves models by declared capabilities, reserves global/system/user budgets atomically in PostgreSQL, records every provider call with normalized usage and cost, and settles idempotently. Blog translate/excerpt routes now run through the shared runner with unchanged responses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR --- AI_IMPLEMENTATION_PROGRESS.md | 83 + .../20261003234938_ai_core/migration.sql | 200 + .../20261003234938_ai_core/snapshot.json | 12042 ++++++++++++++++ .../vitnode/src/api/lib/ai/accounting.test.ts | 484 + .../vitnode/src/api/lib/ai/action.test-d.ts | 57 + packages/vitnode/src/api/lib/ai/action.ts | 315 + packages/vitnode/src/api/lib/ai/budget.ts | 153 + .../vitnode/src/api/lib/ai/capabilities.ts | 35 + packages/vitnode/src/api/lib/ai/decimal.ts | 123 + packages/vitnode/src/api/lib/ai/errors.ts | 65 + .../vitnode/src/api/lib/ai/html-structure.ts | 75 + packages/vitnode/src/api/lib/ai/ledger.ts | 176 + .../vitnode/src/api/lib/ai/memory-ledger.ts | 333 + packages/vitnode/src/api/lib/ai/periods.ts | 128 + .../ai/postgres-ledger.integration.test.ts | 336 + .../vitnode/src/api/lib/ai/postgres-ledger.ts | 716 + packages/vitnode/src/api/lib/ai/pricing.ts | 184 + .../vitnode/src/api/lib/ai/registry.test.ts | 178 + packages/vitnode/src/api/lib/ai/registry.ts | 178 + .../vitnode/src/api/lib/ai/runner.test.ts | 660 + packages/vitnode/src/api/lib/ai/runner.ts | 1098 ++ packages/vitnode/src/api/lib/ai/usage-cost.ts | 207 + packages/vitnode/src/api/lib/plugin.ts | 11 + .../src/api/middlewares/global.middleware.ts | 8 + packages/vitnode/src/api/models/ai.ts | 142 +- packages/vitnode/src/api/models/events.ts | 15 + packages/vitnode/src/database/ai.ts | 360 + packages/vitnode/src/database/relations.ts | 2 + packages/vitnode/src/tests/postgres.ts | 17 +- plugins/blog/src/api/ai/actions.ts | 106 + plugins/blog/src/api/lib/ai-writing.ts | 86 +- .../modules/admin/ai/ai.admin.module.test.ts | 91 +- .../modules/admin/ai/routes/excerpt.route.ts | 30 +- .../admin/ai/routes/translate.route.ts | 37 +- plugins/blog/src/config.api.ts | 2 + 35 files changed, 18625 insertions(+), 108 deletions(-) create mode 100644 AI_IMPLEMENTATION_PROGRESS.md create mode 100644 apps/api/migrations/20261003234938_ai_core/migration.sql create mode 100644 apps/api/migrations/20261003234938_ai_core/snapshot.json create mode 100644 packages/vitnode/src/api/lib/ai/accounting.test.ts create mode 100644 packages/vitnode/src/api/lib/ai/action.test-d.ts create mode 100644 packages/vitnode/src/api/lib/ai/action.ts create mode 100644 packages/vitnode/src/api/lib/ai/budget.ts create mode 100644 packages/vitnode/src/api/lib/ai/capabilities.ts create mode 100644 packages/vitnode/src/api/lib/ai/decimal.ts create mode 100644 packages/vitnode/src/api/lib/ai/errors.ts create mode 100644 packages/vitnode/src/api/lib/ai/html-structure.ts create mode 100644 packages/vitnode/src/api/lib/ai/ledger.ts create mode 100644 packages/vitnode/src/api/lib/ai/memory-ledger.ts create mode 100644 packages/vitnode/src/api/lib/ai/periods.ts create mode 100644 packages/vitnode/src/api/lib/ai/postgres-ledger.integration.test.ts create mode 100644 packages/vitnode/src/api/lib/ai/postgres-ledger.ts create mode 100644 packages/vitnode/src/api/lib/ai/pricing.ts create mode 100644 packages/vitnode/src/api/lib/ai/registry.test.ts create mode 100644 packages/vitnode/src/api/lib/ai/registry.ts create mode 100644 packages/vitnode/src/api/lib/ai/runner.test.ts create mode 100644 packages/vitnode/src/api/lib/ai/runner.ts create mode 100644 packages/vitnode/src/api/lib/ai/usage-cost.ts create mode 100644 packages/vitnode/src/database/ai.ts create mode 100644 plugins/blog/src/api/ai/actions.ts diff --git a/AI_IMPLEMENTATION_PROGRESS.md b/AI_IMPLEMENTATION_PROGRESS.md new file mode 100644 index 000000000..92f707654 --- /dev/null +++ b/AI_IMPLEMENTATION_PROGRESS.md @@ -0,0 +1,83 @@ +# VitNode AI – implementation progress + +Development checkpoint for the shared AI system. User-facing docs live in +`apps/web/content/docs/dev/ai/`. + +## Current stage + +Stage 5 – AdminCP and account usage UI (next). Stages 1–4 core done. + +## Base branch + +`refactor/edit_articles` (PR #842, article translation + excerpt) is not in +`canary`. This branch was fast-forwarded onto it so the blog AI work is +extended rather than recreated. The draft PR is stacked on +`refactor/edit_articles`. + +## Architecture decisions + +- Server-only AI code lives in `packages/vitnode/src/api/lib/ai/`. + Tables in `packages/vitnode/src/database/ai.ts`. +- Canonical action identity: `:`, e.g. + `@vitnode/blog:excerpt.generate`. +- Model capabilities are explicit config metadata (`capabilities`), default + `["text"]`. Vision is `image-input`; image generation stays `imageModel()`. +- Money: decimal strings, fixed-point `bigint` arithmetic (12 decimal places), + PostgreSQL `numeric(24,12)`. 1 AI point = 0.001 USD (conversion version 1). +- Persistence goes through an `AiLedger` interface: `PostgresAiLedger` + (production, proven with real PostgreSQL concurrency tests) and + `MemoryAiLedger` (unit-test seam). +- Budgets: one `core_ai_budget_periods` table keyed by `scopeKey` + period + start (global USD, system USD, user points, user×permission daily count). + Reservation locks rows in sorted `scopeKey` order inside a short + transaction; provider calls never run inside a transaction. + +## Completed + +- Stage 1: `defineAiAction` (`api/lib/ai/action.ts`), capability metadata on + model entries (`capabilities`, default `["text"]`), `AiActionRegistry` + with validation (duplicates, defaults, capabilities, unknown lookups), + `aiActionRef` typed keys, `buildApiPlugin({ aiActions })`, boot-time + cross-plugin validation (`core.aiActions`). Blog `field.translate` and + `excerpt.generate` registered. +- Stage 2: `AiUsage`/`AiCost` discriminated union, decimal money + (`decimal.ts`), pricing rules with cache/tier/flat units (`pricing.ts`), + provider adapters for AI Gateway and OpenRouter + default + (`usage-cost.ts`), cost order provider → effective pricing (manual + override replaces catalog) → unknown. +- Stage 3: `AiRunner` (`runner.ts`) with run / runAsSystem / stream; calls + recorded before they start (crash → uncertain); retries + fallback; + output validation; idempotent settlement; stable `AiError` codes; + `ai.run.completed` / `ai.run.failed` events. Blog routes moved to the + runner, response contract unchanged; HTML translations validated + structurally (`html-structure.ts`). +- Stage 4: points (1 pt = 0.001 USD, version 1), global/system/user/daily + budget periods, rate + concurrency limits, role policy resolution (largest + allowance, any-role grant, user override, root unlimited), atomic + reservations in `PostgresAiLedger`. +- Migration `apps/api/migrations/20261003234938_ai_core`. + +## Checks + +- `packages/vitnode`: `tsc --noEmit` clean; eslint clean on AI files. +- AI unit tests: 68 passed (accounting, registry, runner incl. streaming). +- Type test `action.test-d.ts`: passed. +- Real PostgreSQL integration (`VITNODE_TEST_DATABASE_URL=… vitest run + src/api/lib/ai/postgres-ledger.integration.test.ts`): 9 passed. Removing + the `FOR UPDATE` lock makes 2 of them fail (mutation-checked). +- Blog AI route tests: 8 passed. +- Baseline before changes: core suite 8334 passed. + +## Known limitations + +- Streaming does not retry or fall back once a stream has started. +- Reservations hold the full upper bound up front (all retries and the + fallback), so no mid-run extension is needed; this is conservative. +- CI has no PostgreSQL service; the integration tests skip without + `VITNODE_TEST_DATABASE_URL`. + +## Next task + +Reconciliation + maintenance cron (lease expiry → uncertain, gateway cost +lookup adjustments, history retention), pricing sync, then admin/account +API routes and UI (Stage 5). diff --git a/apps/api/migrations/20261003234938_ai_core/migration.sql b/apps/api/migrations/20261003234938_ai_core/migration.sql new file mode 100644 index 000000000..fe93ee7f8 --- /dev/null +++ b/apps/api/migrations/20261003234938_ai_core/migration.sql @@ -0,0 +1,200 @@ +CREATE TABLE "core_ai_action_settings" ( + "actionKey" varchar(255) PRIMARY KEY, + "enabled" boolean DEFAULT true NOT NULL, + "modelId" varchar(100), + "fallbackModelId" varchar(100), + "maxInputCharacters" integer, + "maxOutputTokens" integer, + "timeoutMs" integer, + "maxRetries" integer, + "maxSteps" integer, + "dailyLimit" integer, + "instructions" text, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_ai_action_settings" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_ai_budget_periods" ( + "id" serial PRIMARY KEY, + "scopeKey" varchar(255) NOT NULL, + "unit" varchar(10) NOT NULL, + "periodStart" timestamp NOT NULL, + "periodEnd" timestamp NOT NULL, + "limitAmount" numeric(24,12), + "spentAmount" numeric(24,12) DEFAULT '0' NOT NULL, + "reservedAmount" numeric(24,12) DEFAULT '0' NOT NULL, + "unknownCount" integer DEFAULT 0 NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_ai_budget_periods" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_ai_calls" ( + "id" serial PRIMARY KEY, + "runId" integer NOT NULL, + "attempt" integer DEFAULT 1 NOT NULL, + "modelId" varchar(100) NOT NULL, + "provider" varchar(100) NOT NULL, + "providerModelId" varchar(255), + "providerRequestId" varchar(255), + "status" varchar(20) NOT NULL, + "errorCode" varchar(64), + "inputTokens" integer, + "outputTokens" integer, + "cacheReadTokens" integer, + "cacheWriteTokens" integer, + "reasoningTokens" integer, + "images" integer DEFAULT 0 NOT NULL, + "costUsd" numeric(24,12), + "costSource" varchar(20) NOT NULL, + "costReason" varchar(255), + "pricingVersion" varchar(100), + "pricingSnapshot" jsonb, + "startedAt" timestamp NOT NULL, + "finishedAt" timestamp, + "reconciledAt" timestamp +); +--> statement-breakpoint +ALTER TABLE "core_ai_calls" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_ai_cost_adjustments" ( + "id" serial PRIMARY KEY, + "callId" integer NOT NULL, + "runId" integer NOT NULL, + "previousCostUsd" numeric(24,12), + "previousSource" varchar(20) NOT NULL, + "newCostUsd" numeric(24,12) NOT NULL, + "newSource" varchar(20) NOT NULL, + "deltaUsd" numeric(24,12) NOT NULL, + "reason" varchar(255) NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_ai_cost_adjustments" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_ai_pricing" ( + "id" serial PRIMARY KEY, + "modelId" varchar(100) NOT NULL, + "source" varchar(20) DEFAULT 'manual' NOT NULL, + "pricing" jsonb NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "createdById" integer, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_ai_pricing" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_ai_reservations" ( + "id" serial PRIMARY KEY, + "runId" integer NOT NULL, + "budgetPeriodId" integer NOT NULL, + "amount" numeric(24,12) NOT NULL, + "status" varchar(10) DEFAULT 'active' NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "settledAt" timestamp +); +--> statement-breakpoint +ALTER TABLE "core_ai_reservations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_ai_role_permissions" ( + "roleId" integer, + "permission" varchar(255), + "granted" boolean DEFAULT true NOT NULL, + "dailyLimit" integer, + CONSTRAINT "core_ai_role_permissions_pkey" PRIMARY KEY("roleId","permission") +); +--> statement-breakpoint +ALTER TABLE "core_ai_role_permissions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_ai_role_policies" ( + "roleId" integer PRIMARY KEY, + "monthlyPoints" numeric(24,12), + "unlimited" boolean DEFAULT false NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_ai_role_policies" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_ai_runs" ( + "id" serial PRIMARY KEY, + "actionKey" varchar(255) NOT NULL, + "pluginId" varchar(100) NOT NULL, + "actorType" varchar(10) NOT NULL, + "userId" integer, + "idempotencyKey" varchar(255), + "resourceType" varchar(100), + "resourceId" varchar(255), + "status" varchar(20) NOT NULL, + "errorCode" varchar(64), + "promptVersion" integer NOT NULL, + "pointsConversionVersion" integer NOT NULL, + "modelId" varchar(100), + "provider" varchar(100), + "providerModelId" varchar(255), + "inputTokens" integer, + "outputTokens" integer, + "cacheReadTokens" integer, + "cacheWriteTokens" integer, + "reasoningTokens" integer, + "costUsd" numeric(24,12), + "costSource" varchar(20), + "chargedUsd" numeric(24,12), + "chargedPoints" numeric(24,12), + "reservedUsd" numeric(24,12) DEFAULT '0' NOT NULL, + "reservedPoints" numeric(24,12) DEFAULT '0' NOT NULL, + "settlement" varchar(20) DEFAULT 'pending' NOT NULL, + "leaseExpiresAt" timestamp, + "accepted" boolean, + "acceptedAt" timestamp, + "sourceFingerprint" varchar(64), + "createdAt" timestamp DEFAULT now() NOT NULL, + "startedAt" timestamp, + "finishedAt" timestamp, + "settledAt" timestamp +); +--> statement-breakpoint +ALTER TABLE "core_ai_runs" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_ai_settings" ( + "id" integer PRIMARY KEY DEFAULT 1, + "enabled" boolean DEFAULT true NOT NULL, + "monthlyBudgetUsd" numeric(24,12), + "systemMonthlyBudgetUsd" numeric(24,12), + "defaultMonthlyPoints" numeric(24,12) DEFAULT '0' NOT NULL, + "pointsConversionVersion" integer DEFAULT 1 NOT NULL, + "userRequestsPerMinute" integer DEFAULT 10 NOT NULL, + "userConcurrency" integer DEFAULT 2 NOT NULL, + "systemConcurrency" integer DEFAULT 2 NOT NULL, + "historyRetentionDays" integer DEFAULT 365 NOT NULL, + "altEnabled" boolean DEFAULT false NOT NULL, + "altLanguages" jsonb, + "altBatchSize" integer DEFAULT 10 NOT NULL, + "altScanCursor" integer DEFAULT 0 NOT NULL, + "altScanStartedAt" timestamp, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_ai_settings" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_ai_user_overrides" ( + "userId" integer PRIMARY KEY, + "monthlyPoints" numeric(24,12), + "unlimited" boolean DEFAULT false NOT NULL, + "blocked" boolean DEFAULT false NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_ai_user_overrides" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE UNIQUE INDEX "core_ai_budget_periods_scope_unique" ON "core_ai_budget_periods" ("scopeKey","periodStart");--> statement-breakpoint +CREATE INDEX "core_ai_calls_run_idx" ON "core_ai_calls" ("runId");--> statement-breakpoint +CREATE INDEX "core_ai_calls_reconcile_idx" ON "core_ai_calls" ("costSource","reconciledAt");--> statement-breakpoint +CREATE UNIQUE INDEX "core_ai_cost_adjustments_call_unique" ON "core_ai_cost_adjustments" ("callId");--> statement-breakpoint +CREATE UNIQUE INDEX "core_ai_pricing_active_unique" ON "core_ai_pricing" ("modelId","source") WHERE active;--> statement-breakpoint +CREATE UNIQUE INDEX "core_ai_reservations_run_period_unique" ON "core_ai_reservations" ("runId","budgetPeriodId");--> statement-breakpoint +CREATE INDEX "core_ai_reservations_status_idx" ON "core_ai_reservations" ("status");--> statement-breakpoint +CREATE INDEX "core_ai_runs_user_created_idx" ON "core_ai_runs" ("userId","createdAt");--> statement-breakpoint +CREATE INDEX "core_ai_runs_created_idx" ON "core_ai_runs" ("createdAt","id");--> statement-breakpoint +CREATE INDEX "core_ai_runs_action_created_idx" ON "core_ai_runs" ("actionKey","createdAt");--> statement-breakpoint +CREATE INDEX "core_ai_runs_status_idx" ON "core_ai_runs" ("status","leaseExpiresAt");--> statement-breakpoint +CREATE UNIQUE INDEX "core_ai_runs_idempotency_unique" ON "core_ai_runs" ("actorType","userId","idempotencyKey") WHERE "idempotencyKey" IS NOT NULL;--> statement-breakpoint +ALTER TABLE "core_ai_calls" ADD CONSTRAINT "core_ai_calls_runId_core_ai_runs_id_fkey" FOREIGN KEY ("runId") REFERENCES "core_ai_runs"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_ai_cost_adjustments" ADD CONSTRAINT "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey" FOREIGN KEY ("callId") REFERENCES "core_ai_calls"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_ai_cost_adjustments" ADD CONSTRAINT "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey" FOREIGN KEY ("runId") REFERENCES "core_ai_runs"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_ai_pricing" ADD CONSTRAINT "core_ai_pricing_createdById_core_users_id_fkey" FOREIGN KEY ("createdById") REFERENCES "core_users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_ai_reservations" ADD CONSTRAINT "core_ai_reservations_runId_core_ai_runs_id_fkey" FOREIGN KEY ("runId") REFERENCES "core_ai_runs"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_ai_reservations" ADD CONSTRAINT "core_ai_reservations_FClYpPXZwvsm_fkey" FOREIGN KEY ("budgetPeriodId") REFERENCES "core_ai_budget_periods"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_ai_role_permissions" ADD CONSTRAINT "core_ai_role_permissions_roleId_core_roles_id_fkey" FOREIGN KEY ("roleId") REFERENCES "core_roles"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_ai_role_policies" ADD CONSTRAINT "core_ai_role_policies_roleId_core_roles_id_fkey" FOREIGN KEY ("roleId") REFERENCES "core_roles"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_ai_runs" ADD CONSTRAINT "core_ai_runs_userId_core_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "core_users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_ai_user_overrides" ADD CONSTRAINT "core_ai_user_overrides_userId_core_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "core_users"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/apps/api/migrations/20261003234938_ai_core/snapshot.json b/apps/api/migrations/20261003234938_ai_core/snapshot.json new file mode 100644 index 000000000..8d54c7214 --- /dev/null +++ b/apps/api/migrations/20261003234938_ai_core/snapshot.json @@ -0,0 +1,12042 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "4dce261e-67cc-4ef1-9c6f-95994b801466", + "prevIds": [ + "bb799038-c9f7-4c6a-83ac-68898c684511" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_pricing", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'manual'", + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricing", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "active", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altLanguages", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "periodStart", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_budget_periods_scope_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_run_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "costSource", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reconciledAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_reconcile_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "callId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_cost_adjustments_call_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "modelId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "source", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "active", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_pricing_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "budgetPeriodId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_run_period_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actionKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_action_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "leaseExpiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotencyKey\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "createdById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_pricing_createdById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_pricing_pkey", + "schema": "public", + "table": "core_ai_pricing", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/vitnode/src/api/lib/ai/accounting.test.ts b/packages/vitnode/src/api/lib/ai/accounting.test.ts new file mode 100644 index 000000000..9725cc718 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/accounting.test.ts @@ -0,0 +1,484 @@ +// @vitest-environment node +import type { LanguageModelUsage } from "ai"; + +import { describe, expect, it } from "vitest"; + +import type { AiPricing } from "./pricing"; + +import { + findExceededBudget, + planBudgetItems, + settlementCharge, +} from "./budget"; +import { + decimalFromNumber, + formatDecimal, + parseDecimal, + sumDecimals, +} from "./decimal"; +import { usdToPoints } from "./ledger"; +import { periodContaining } from "./periods"; +import { resolvePolicy } from "./postgres-ledger"; +import { maxCallCost, priceUsage } from "./pricing"; +import { + defaultProviderAdapter, + gatewayProviderAdapter, + normalizeUsage, + openRouterProviderAdapter, + resolveCost, + resolveProviderAdapter, +} from "./usage-cost"; + +const usd = (value: string) => parseDecimal(value); + +const SONNET: AiPricing = { + rates: { + cacheReadPerMillion: "0.30", + cacheWritePerMillion: "3.75", + inputPerMillion: "3", + outputPerMillion: "15", + }, + tiers: [ + { + aboveInputTokens: 200_000, + rates: { inputPerMillion: "6", outputPerMillion: "22.5" }, + }, + ], +}; + +const usage = ( + overrides: Partial<{ + cacheReadTokens: null | number; + cacheWriteTokens: null | number; + inputTokens: null | number; + outputTokens: null | number; + reasoningTokens: null | number; + }> = {}, +) => ({ + cacheReadTokens: 0, + cacheWriteTokens: 0, + inputTokens: 1_000, + outputTokens: 100, + reasoningTokens: null, + ...overrides, +}); + +describe("decimal money", () => { + it("adds sub-cent amounts without float drift", () => { + const total = sumDecimals(Array.from({ length: 10 }, () => usd("0.1"))); + + expect(formatDecimal(total)).toBe("1"); + expect(formatDecimal(sumDecimals([usd("0.1"), usd("0.2")]))).toBe("0.3"); + }); + + it("reads provider floats through their decimal text", () => { + expect(formatDecimal(decimalFromNumber(0.000123))).toBe("0.000123"); + }); + + it("converts USD to points at 1 point = 0.001 USD, keeping precision", () => { + expect(formatDecimal(usdToPoints(usd("0.0003")))).toBe("0.3"); + expect(formatDecimal(usdToPoints(usd("0.003")))).toBe("3"); + expect(formatDecimal(usdToPoints(usd("0.012")))).toBe("12"); + expect(formatDecimal(usdToPoints(usd("0.0000001")))).toBe("0.0001"); + }); +}); + +describe("pricing", () => { + it("prices uncached input, cache reads, cache writes and output separately", () => { + const result = priceUsage( + SONNET, + usage({ + cacheReadTokens: 2_000, + cacheWriteTokens: 1_000, + inputTokens: 10_000, + outputTokens: 500, + }), + ); + + // 7000*3 + 2000*0.3 + 1000*3.75 + 500*15 = 21000+600+3750+7500 per 1M + expect(result).toEqual({ amount: usd("0.03285"), status: "complete" }); + }); + + it("never bills reasoning tokens on top of output tokens", () => { + const withReasoning = priceUsage( + SONNET, + usage({ outputTokens: 1_000, reasoningTokens: 800 }), + ); + const without = priceUsage(SONNET, usage({ outputTokens: 1_000 })); + + expect(withReasoning).toEqual(without); + }); + + it("bills the whole call at the long-context tier above its threshold", () => { + const result = priceUsage( + SONNET, + usage({ inputTokens: 300_000, outputTokens: 1_000 }), + ); + + expect(result).toEqual({ amount: usd("1.8225"), status: "complete" }); + }); + + it("refuses to call an incomplete price exact", () => { + const noCachePrice: AiPricing = { + rates: { inputPerMillion: "1", outputPerMillion: "2" }, + }; + + expect(priceUsage(noCachePrice, usage({ cacheReadTokens: 10 }))).toEqual({ + reason: "no cache read price", + status: "incomplete", + }); + expect(priceUsage(SONNET, usage({ inputTokens: null }))).toMatchObject({ + status: "incomplete", + }); + }); + + it("adds flat per-request and per-image units", () => { + const flat: AiPricing = { + rates: { + inputPerMillion: "0", + outputPerMillion: "0", + perImage: "0.002", + perRequest: "0.0001", + }, + }; + + expect(priceUsage(flat, usage(), { images: 2 })).toEqual({ + amount: usd("0.0041"), + status: "complete", + }); + }); + + it("bounds a call by the dearest input rate and tier it could reach", () => { + const bound = maxCallCost(SONNET, { + images: 0, + inputTokens: 300_000, + outputTokens: 1_000, + }); + + // Tier rates (6 in, 22.5 out) beat the base tier's cache-write rate. + expect(formatDecimal(bound)).toBe("1.8225"); + expect( + formatDecimal( + maxCallCost(SONNET, { images: 0, inputTokens: 1_000, outputTokens: 0 }), + ), + ).toBe("0.00375"); + }); +}); + +describe("usage normalization", () => { + const sdkUsage = ( + overrides: Partial = {}, + ): LanguageModelUsage => ({ + inputTokenDetails: { + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + noCacheTokens: undefined, + }, + inputTokens: 10, + outputTokenDetails: { reasoningTokens: undefined, textTokens: undefined }, + outputTokens: 5, + totalTokens: 15, + ...overrides, + }); + + it("keeps unknown as null and a reported zero as zero", () => { + const normalized = normalizeUsage( + sdkUsage({ + inputTokenDetails: { + cacheReadTokens: 0, + cacheWriteTokens: undefined, + noCacheTokens: 10, + }, + }), + ); + + expect(normalized.cacheReadTokens).toBe(0); + expect(normalized.cacheWriteTokens).toBeNull(); + expect(normalizeUsage(undefined).inputTokens).toBeNull(); + }); + + it("derives total output from its split when only the split is reported", () => { + const normalized = normalizeUsage( + sdkUsage({ + outputTokenDetails: { reasoningTokens: 30, textTokens: 20 }, + outputTokens: undefined, + }), + ); + + expect(normalized.outputTokens).toBe(50); + expect(normalized.reasoningTokens).toBe(30); + }); +}); + +describe("cost resolution", () => { + const pricing = { + pricing: SONNET, + source: "pricing" as const, + version: "config:abc", + }; + + it("prefers the cost the provider reported", () => { + expect( + resolveCost({ pricing, reportedCost: usd("0.0042"), usage: usage() }), + ).toEqual({ amountUsd: "0.0042", source: "provider" }); + }); + + it("estimates from pricing and names the version", () => { + expect( + resolveCost({ pricing, reportedCost: null, usage: usage() }), + ).toEqual({ + amountUsd: "0.0045", + pricingVersion: "config:abc", + source: "pricing", + }); + }); + + it("labels an admin override as manual, never as the catalog price", () => { + const cost = resolveCost({ + pricing: { ...pricing, source: "manual", version: "manual:7" }, + reportedCost: null, + usage: usage(), + }); + + expect(cost).toMatchObject({ + pricingVersion: "manual:7", + source: "manual", + }); + }); + + it("is unknown - not zero - without pricing", () => { + const cost = resolveCost({ + pricing: null, + reportedCost: null, + usage: usage(), + }); + + expect(cost).toEqual({ + amountUsd: null, + reason: "no pricing", + source: "unknown", + }); + }); +}); + +describe("provider adapters", () => { + const adapters = [gatewayProviderAdapter(), openRouterProviderAdapter]; + + it("reads the gateway's generation id and reported cost", () => { + const adapter = resolveProviderAdapter(adapters, "gateway"); + const metadata = { gateway: { cost: "0.00012", generationId: "gen_1" } }; + + expect(adapter.reportedCost(metadata)).toBe(usd("0.00012")); + expect(adapter.requestId({ metadata, responseId: "resp" })).toBe("gen_1"); + expect(adapter.reportedCost({ gateway: {} })).toBeNull(); + }); + + it("reads OpenRouter's usage cost", () => { + const adapter = resolveProviderAdapter(adapters, "openrouter.chat"); + + expect(adapter.reportedCost({ openrouter: { usage: { cost: 0.5 } } })).toBe( + usd("0.5"), + ); + }); + + it("treats direct providers as reporting no cost", () => { + const adapter = resolveProviderAdapter(adapters, "anthropic.messages"); + + expect(adapter).toBe(defaultProviderAdapter); + expect( + adapter.requestId({ metadata: undefined, responseId: "msg_1" }), + ).toBe("msg_1"); + }); +}); + +describe("calendar periods", () => { + it("starts a month at local midnight in the site's time zone", () => { + const period = periodContaining( + new Date("2026-10-15T12:00:00Z"), + "month", + "Europe/Warsaw", + ); + + expect(period.start.toISOString()).toBe("2026-09-30T22:00:00.000Z"); + // October 25th switches Warsaw to winter time: November starts at 23:00 UTC. + expect(period.end.toISOString()).toBe("2026-10-31T23:00:00.000Z"); + }); + + it("puts an instant just after local midnight in the new day", () => { + const period = periodContaining( + new Date("2026-03-01T05:30:00Z"), + "day", + "America/New_York", + ); + + expect(period.start.toISOString()).toBe("2026-03-01T05:00:00.000Z"); + expect(period.end.toISOString()).toBe("2026-03-02T05:00:00.000Z"); + }); + + it("falls back to UTC for an unknown zone", () => { + const period = periodContaining( + new Date("2026-10-15T12:00:00Z"), + "month", + "Not/AZone", + ); + + expect(period.start.toISOString()).toBe("2026-10-01T00:00:00.000Z"); + }); +}); + +describe("role policies", () => { + const base = { + defaultGranted: false, + defaultMonthlyPoints: usd("100"), + override: null, + permissions: [], + policies: [], + root: false, + }; + + it("uses the largest allowance of a user's roles and never sums them", () => { + const policy = resolvePolicy({ + ...base, + permissions: [{ dailyLimit: 5, granted: true }], + policies: [ + { monthlyPoints: usd("500"), unlimited: false }, + { monthlyPoints: usd("2000"), unlimited: false }, + ], + }); + + expect(policy.monthlyPoints).toBe(usd("2000")); + }); + + it("grants when any role grants, with the largest daily limit", () => { + const policy = resolvePolicy({ + ...base, + permissions: [ + { dailyLimit: 5, granted: true }, + { dailyLimit: 20, granted: true }, + { dailyLimit: null, granted: false }, + ], + }); + + expect(policy).toMatchObject({ dailyLimit: 20, granted: true }); + }); + + it("falls back to the action's default when no role says anything", () => { + expect(resolvePolicy({ ...base, defaultGranted: true }).granted).toBe(true); + expect(resolvePolicy(base).granted).toBe(false); + }); + + it("lets an explicit user override replace what the roles give", () => { + const policy = resolvePolicy({ + ...base, + override: { blocked: false, monthlyPoints: usd("5"), unlimited: false }, + policies: [{ monthlyPoints: null, unlimited: true }], + }); + + expect(policy.monthlyPoints).toBe(usd("5")); + expect( + resolvePolicy({ + ...base, + override: { blocked: true, monthlyPoints: null, unlimited: false }, + root: true, + }).granted, + ).toBe(false); + }); + + it("gives root roles access with no personal cap", () => { + expect(resolvePolicy({ ...base, root: true })).toEqual({ + dailyLimit: null, + granted: true, + monthlyPoints: null, + }); + }); +}); + +describe("budget planning", () => { + const now = new Date("2026-10-15T12:00:00Z"); + + it("holds global, personal points and the daily counter for a user run, in lock order", () => { + const items = planBudgetItems({ + actorType: "user", + daily: { limit: 3, permissionKey: "@vitnode/blog:excerpt" }, + globalMonthlyUsd: usd("10"), + now, + systemMonthlyUsd: null, + timeZone: "UTC", + usd: usd("0.004"), + userId: 7, + userMonthlyPoints: usd("100"), + }); + + expect(items.map(item => item.scopeKey)).toEqual([ + "global", + "user:7", + "user:7:daily:@vitnode/blog:excerpt", + ]); + expect(formatDecimal(items[1].amount)).toBe("4"); + }); + + it("never touches personal budgets for system work", () => { + const items = planBudgetItems({ + actorType: "system", + daily: null, + globalMonthlyUsd: usd("10"), + now, + systemMonthlyUsd: null, + timeZone: "UTC", + usd: usd("0.004"), + userId: null, + userMonthlyPoints: usd("0"), + }); + + expect(items.map(item => item.scopeKey)).toEqual(["global", "system"]); + }); + + it("refuses a run that would overspend, and an unpriced run under a cap", () => { + const items = planBudgetItems({ + actorType: "system", + daily: null, + globalMonthlyUsd: usd("1"), + now, + systemMonthlyUsd: null, + timeZone: "UTC", + usd: usd("0.5"), + userId: null, + userMonthlyPoints: null, + }); + const rows = new Map([ + [ + "global", + { + limitAmount: usd("1"), + periodEnd: items[0].period.end, + reservedAmount: usd("0.3"), + scopeKey: "global", + spentAmount: usd("0.3"), + }, + ], + ]); + + expect(findExceededBudget(items, rows, { priced: true })?.code).toBe( + "AI_BUDGET_EXHAUSTED", + ); + expect(findExceededBudget(items, new Map(), { priced: false })?.code).toBe( + "AI_PRICING_MISSING", + ); + }); + + it("charges points only for delivered results, money always", () => { + const chargedUsd = usd("0.002"); + + expect( + settlementCharge({ chargedUsd, delivered: false, unit: "points" }), + ).toBe(0n); + expect( + settlementCharge({ chargedUsd, delivered: false, unit: "usd" }), + ).toBe(chargedUsd); + expect( + formatDecimal( + settlementCharge({ chargedUsd, delivered: true, unit: "points" }), + ), + ).toBe("2"); + }); +}); diff --git a/packages/vitnode/src/api/lib/ai/action.test-d.ts b/packages/vitnode/src/api/lib/ai/action.test-d.ts new file mode 100644 index 000000000..905da5db7 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/action.test-d.ts @@ -0,0 +1,57 @@ +import { describe, expectTypeOf, it } from "vitest"; +import { z } from "zod"; + +import { defineAiAction } from "./action"; + +describe("defineAiAction inference", () => { + it("types buildPrompt and parseText from the schemas", () => { + const action = defineAiAction({ + authorize: ({ input }) => { + expectTypeOf(input).toEqualTypeOf<{ locale: string; title: string }>(); + + return true; + }, + buildPrompt: input => { + expectTypeOf(input.title).toBeString(); + + return { prompt: input.title }; + }, + defaults: { + maxInputCharacters: 10, + maxOutputTokens: 10, + timeoutMs: 5_000, + }, + id: "title.rewrite", + inputSchema: z.object({ locale: z.string(), title: z.string() }), + output: "text", + outputSchema: z.object({ title: z.string() }), + parseText: text => ({ title: text }), + permission: "title", + promptVersion: 1, + requiredCapabilities: ["text"], + }); + + expectTypeOf(action.id).toEqualTypeOf<"title.rewrite">(); + }); + + it("rejects an unknown capability at compile time", () => { + defineAiAction({ + actors: ["system"], + buildPrompt: () => ({ prompt: "x" }), + defaults: { + maxInputCharacters: 10, + maxOutputTokens: 10, + timeoutMs: 5_000, + }, + id: "x.y", + inputSchema: z.object({}), + output: "text", + outputSchema: z.string(), + parseText: text => text, + permission: "x", + promptVersion: 1, + // @ts-expect-error - "telepathy" is not a model capability. + requiredCapabilities: ["telepathy"], + }); + }); +}); diff --git a/packages/vitnode/src/api/lib/ai/action.ts b/packages/vitnode/src/api/lib/ai/action.ts new file mode 100644 index 000000000..b8a6b7b9e --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/action.ts @@ -0,0 +1,315 @@ +import type { ModelMessage } from "ai"; +import type { Context } from "hono"; +import type { z } from "zod"; + +import type { EnvVitNode } from "../../middlewares/global.middleware"; +import type { AiModelCapability } from "./capabilities"; + +import { isAiModelCapability } from "./capabilities"; + +/** Who an AI run is for. Decided by the server, never by the request. */ +export type AiActorType = "system" | "user"; + +export interface AiResourceRef { + id: null | number | string; + type: string; +} + +/** What a prompt builder hands to the Vercel AI SDK. */ +export type AiPrompt = + | { messages: ModelMessage[]; system?: string } + | { prompt: string; system?: string }; + +export interface AiPromptContext { + /** Extra editorial instructions an admin set for this action, if any. */ + instructions: null | string; +} + +export interface AiAuthorizeContext { + c: Context; + input: Input; + resource: AiResourceRef | undefined; + userId: number; +} + +/** + * The permission a role needs to use the action. Several actions can share + * one key - every Quick Ask variant needs `editor.assist`, for example. + * `defaultGranted` is what applies while no role has a row for the key. + */ +export interface AiPermissionDescriptor { + defaultGranted: boolean; + key: string; +} + +export interface AiActionDefaults { + /** Optional per-user daily invocation limit; `null` for none. */ + dailyLimit: null | number; + /** Images sent in one call - bounds the reservation of a vision action. */ + maxImages: number; + maxInputCharacters: number; + maxOutputTokens: number; + /** Extra attempts after a failed provider call. */ + maxRetries: number; + /** Model round trips per attempt (tool loops). */ + maxSteps: number; + timeoutMs: number; +} + +export type AiActionOutputMode = "object" | "text"; + +/* eslint-disable @typescript-eslint/method-signature-style -- method + signatures are bivariant, which keeps a typed action assignable to the + registry's `AnyAiActionDefinition`. */ +interface AiActionDefinitionBase< + Id extends string, + InputSchema extends z.ZodType, + OutputSchema extends z.ZodType, +> { + /** `system` actions run only from trusted server code (cron, queue). */ + actors?: readonly AiActorType[]; + /** + * Checks the actor may touch the resource the input came from. Required for + * user actions: an AI permission never replaces access to the content. + */ + authorize?( + context: AiAuthorizeContext>, + ): boolean | Promise; + buildPrompt(input: z.output, context: AiPromptContext): AiPrompt; + defaults: Partial & + Pick< + AiActionDefaults, + "maxInputCharacters" | "maxOutputTokens" | "timeoutMs" + >; + /** One line shown in the AdminCP. */ + description?: string; + /** Local id, unique within the plugin: `excerpt.generate`. */ + id: Id; + inputSchema: InputSchema; + /** Characters counted against `maxInputCharacters`. Defaults to every string in the input. */ + measureInput?(input: z.output): number; + outputSchema: OutputSchema; + permission: AiPermissionDescriptor | string; + /** Bump whenever the prompt changes meaningfully; it is stored with every run. */ + promptVersion: number; + requiredCapabilities: readonly AiModelCapability[]; +} + +export interface AiTextActionDefinition< + Id extends string, + InputSchema extends z.ZodType, + OutputSchema extends z.ZodType, +> extends AiActionDefinitionBase { + output: "text"; + /** Turns the model's text into the output, before the output schema checks it. */ + parseText(text: string, input: z.output): z.input; +} + +export interface AiObjectActionDefinition< + Id extends string, + InputSchema extends z.ZodType, + OutputSchema extends z.ZodType, +> extends AiActionDefinitionBase { + output: "object"; +} + +/* eslint-enable @typescript-eslint/method-signature-style */ + +export type AiActionDefinition< + Id extends string = string, + InputSchema extends z.ZodType = z.ZodType, + OutputSchema extends z.ZodType = z.ZodType, +> = + | AiObjectActionDefinition + | AiTextActionDefinition; + +/** A definition after `defineAiAction` filled in its defaults. */ +export type ResolvedAiActionDefinition< + Id extends string = string, + InputSchema extends z.ZodType = z.ZodType, + OutputSchema extends z.ZodType = z.ZodType, +> = Omit< + AiActionDefinition, + "actors" | "defaults" | "permission" +> & + ( + | Pick, "output"> + | Pick< + AiTextActionDefinition, + "output" | "parseText" + > + ) & { + actors: readonly AiActorType[]; + defaults: AiActionDefaults; + permission: AiPermissionDescriptor; + }; + +export type AnyAiActionDefinition = ResolvedAiActionDefinition; + +export class AiActionDefinitionError extends Error { + constructor(message: string) { + super(message); + this.name = "AiActionDefinitionError"; + } +} + +export const AI_ACTION_LIMITS = { + maxInputCharacters: { max: 1_000_000, min: 1 }, + maxOutputTokens: { max: 200_000, min: 1 }, + timeoutMs: { max: 600_000, min: 1_000 }, + maxRetries: { max: 3, min: 0 }, + maxSteps: { max: 10, min: 1 }, + maxImages: { max: 10, min: 0 }, + dailyLimit: { max: 100_000, min: 0 }, +} as const satisfies Record< + keyof AiActionDefaults, + { max: number; min: number } +>; + +const LOCAL_ID_PATTERN = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/; +const PERMISSION_KEY_PATTERN = LOCAL_ID_PATTERN; + +const assertInRange = ( + actionId: string, + field: keyof AiActionDefaults, + value: number, +) => { + const { max, min } = AI_ACTION_LIMITS[field]; + if (!Number.isInteger(value) || value < min || value > max) { + throw new AiActionDefinitionError( + `AI action "${actionId}" has an invalid default ${field}: ${value}. Use a whole number from ${min} to ${max}.`, + ); + } +}; + +/** Checks a limit an admin or a definition chose. Exported for the settings routes. */ +export const isValidAiLimit = ( + field: keyof AiActionDefaults, + value: number, +): boolean => { + const { max, min } = AI_ACTION_LIMITS[field]; + + return Number.isInteger(value) && value >= min && value <= max; +}; + +/** + * Declares one AI action a plugin offers. The plugin hands the result to + * `buildApiPlugin({ aiActions })`; Core owns the model, the limits and the + * accounting of every run. + */ +export function defineAiAction< + const Id extends string, + InputSchema extends z.ZodType, + OutputSchema extends z.ZodType, +>( + definition: AiActionDefinition, +): ResolvedAiActionDefinition { + const { id } = definition; + + if (!LOCAL_ID_PATTERN.test(id)) { + throw new AiActionDefinitionError( + `AI action id "${id}" is invalid. Use lowercase dot-separated words, e.g. "excerpt.generate".`, + ); + } + + if ( + !Number.isInteger(definition.promptVersion) || + definition.promptVersion < 1 + ) { + throw new AiActionDefinitionError( + `AI action "${id}" needs a whole-number promptVersion of 1 or more.`, + ); + } + + if (definition.requiredCapabilities.length === 0) { + throw new AiActionDefinitionError( + `AI action "${id}" declares no requiredCapabilities. Every action needs at least "text".`, + ); + } + for (const capability of definition.requiredCapabilities) { + if (!isAiModelCapability(capability)) { + throw new AiActionDefinitionError( + `AI action "${id}" requires the unsupported capability "${String(capability)}".`, + ); + } + } + if ( + definition.output === "object" && + !definition.requiredCapabilities.includes("structured-output") + ) { + throw new AiActionDefinitionError( + `AI action "${id}" returns an object, so it must require the "structured-output" capability.`, + ); + } + + const permission: AiPermissionDescriptor = + typeof definition.permission === "string" + ? { key: definition.permission, defaultGranted: false } + : definition.permission; + if (!PERMISSION_KEY_PATTERN.test(permission.key)) { + throw new AiActionDefinitionError( + `AI action "${id}" has an invalid permission key "${permission.key}".`, + ); + } + + const defaults: AiActionDefaults = { + dailyLimit: null, + maxImages: definition.requiredCapabilities.includes("image-input") ? 1 : 0, + maxRetries: 0, + maxSteps: 1, + ...definition.defaults, + }; + for (const field of Object.keys( + AI_ACTION_LIMITS, + ) as (keyof AiActionDefaults)[]) { + const value = defaults[field]; + if (value === null) continue; + assertInRange(id, field, value); + } + if ( + definition.requiredCapabilities.includes("image-input") && + defaults.maxImages < 1 + ) { + throw new AiActionDefinitionError( + `AI action "${id}" requires "image-input", so defaults.maxImages must be at least 1.`, + ); + } + + const actors = definition.actors ?? ["user"]; + if (actors.length === 0) { + throw new AiActionDefinitionError( + `AI action "${id}" allows no actors. Use ["user"], ["system"] or both.`, + ); + } + if (actors.includes("user") && !definition.authorize) { + throw new AiActionDefinitionError( + `AI action "${id}" can run for users, so it needs an authorize() check for the content it reads.`, + ); + } + + return { + ...definition, + actors, + defaults, + permission, + }; +} + +/** Characters in every string of a value - the default input measure. */ +export const countInputCharacters = (value: unknown): number => { + if (typeof value === "string") return value.length; + if (Array.isArray(value)) { + return value.reduce( + (total, item) => total + countInputCharacters(item), + 0, + ); + } + if (value && typeof value === "object") { + return Object.values(value).reduce( + (total, item) => total + countInputCharacters(item), + 0, + ); + } + + return 0; +}; diff --git a/packages/vitnode/src/api/lib/ai/budget.ts b/packages/vitnode/src/api/lib/ai/budget.ts new file mode 100644 index 000000000..e924860e1 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/budget.ts @@ -0,0 +1,153 @@ +import type { Decimal } from "./decimal"; +import type { AiErrorCode } from "./errors"; +import type { AiReservationRequest } from "./ledger"; +import type { AiPeriod } from "./periods"; + +import { decimalFromInteger } from "./decimal"; +import { usdToPoints } from "./ledger"; +import { periodContaining } from "./periods"; + +export type AiBudgetUnit = "count" | "points" | "usd"; + +/** One budget or counter a run must hold room in. */ +export interface AiBudgetItem { + amount: Decimal; + exceededCode: AiErrorCode; + limit: Decimal | null; + period: AiPeriod; + scopeKey: string; + unit: AiBudgetUnit; +} + +export interface AiBudgetRowState { + limitAmount: Decimal | null; + periodEnd: Date; + reservedAmount: Decimal; + scopeKey: string; + spentAmount: Decimal; +} + +export const GLOBAL_SCOPE = "global"; +export const SYSTEM_SCOPE = "system"; +export const userScope = (userId: number) => `user:${userId}`; +export const userDailyScope = (userId: number, permissionKey: string) => + `user:${userId}:daily:${permissionKey}`; + +/** + * Every budget the request touches, sorted by `scopeKey` - the one lock order + * every reservation and settlement uses, so two runs can never deadlock. + * + * The system actor never touches a user's points or daily counters; it still + * holds room in the global budget. + */ +export const planBudgetItems = ( + request: Pick< + AiReservationRequest, + | "actorType" + | "daily" + | "globalMonthlyUsd" + | "now" + | "systemMonthlyUsd" + | "timeZone" + | "usd" + | "userId" + | "userMonthlyPoints" + >, +): AiBudgetItem[] => { + const month = periodContaining(request.now, "month", request.timeZone); + const usd = request.usd ?? 0n; + const items: AiBudgetItem[] = [ + { + amount: usd, + exceededCode: "AI_BUDGET_EXHAUSTED", + limit: request.globalMonthlyUsd, + period: month, + scopeKey: GLOBAL_SCOPE, + unit: "usd", + }, + ]; + + if (request.actorType === "system") { + items.push({ + amount: usd, + exceededCode: "AI_BUDGET_EXHAUSTED", + limit: request.systemMonthlyUsd, + period: month, + scopeKey: SYSTEM_SCOPE, + unit: "usd", + }); + } else if (request.userId !== null) { + items.push({ + amount: usdToPoints(usd), + exceededCode: "AI_USER_LIMIT_REACHED", + limit: request.userMonthlyPoints, + period: month, + scopeKey: userScope(request.userId), + unit: "points", + }); + if (request.daily) { + items.push({ + amount: decimalFromInteger(1), + exceededCode: "AI_DAILY_LIMIT_REACHED", + limit: + request.daily.limit === null + ? null + : decimalFromInteger(request.daily.limit), + period: periodContaining(request.now, "day", request.timeZone), + scopeKey: userDailyScope(request.userId, request.daily.permissionKey), + unit: "count", + }); + } + } + + return items.sort((a, b) => (a.scopeKey < b.scopeKey ? -1 : 1)); +}; + +/** + * The first budget the run does not fit in, or `null` when it fits all. + * A capped money budget cannot be held without an upper bound, so an + * unpriced run is refused there instead of being let through for free. + */ +export const findExceededBudget = ( + items: AiBudgetItem[], + rows: Map, + { priced }: { priced: boolean }, +): null | { code: AiErrorCode; resetsAt: Date } => { + for (const item of items) { + if (item.limit === null) continue; + if (!priced && item.unit !== "count") { + return { code: "AI_PRICING_MISSING", resetsAt: item.period.end }; + } + const row = rows.get(item.scopeKey); + const used = (row?.spentAmount ?? 0n) + (row?.reservedAmount ?? 0n); + if (used + item.amount > item.limit) { + return { code: item.exceededCode, resetsAt: item.period.end }; + } + } + + return null; +}; + +/** + * What settling one reservation moves into `spent`. + * + * - Money budgets always pay what the site paid. An unknown cost is charged + * the full reservation - never zero. + * - Personal points and daily counts are charged only when a valid result was + * delivered. A provider failure or a cancellation costs the user nothing. + */ +export const settlementCharge = ({ + chargedUsd, + delivered, + unit, +}: { + chargedUsd: Decimal; + delivered: boolean; + unit: AiBudgetUnit; +}): Decimal => { + if (unit === "usd") return chargedUsd; + if (!delivered) return 0n; + if (unit === "points") return usdToPoints(chargedUsd); + + return decimalFromInteger(1); +}; diff --git a/packages/vitnode/src/api/lib/ai/capabilities.ts b/packages/vitnode/src/api/lib/ai/capabilities.ts new file mode 100644 index 000000000..34fcde5c7 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/capabilities.ts @@ -0,0 +1,35 @@ +/** + * What a configured language model can do, declared in the API config. Never + * inferred from a model's name: a model whose entry says nothing is treated as + * text-only, so nothing ever sends an image to a model nobody said accepts one. + * + * - `text` - text in, text out. + * - `image-input` - accepts images in the prompt (vision). Image ALT text needs + * this. It is *not* image generation, which is `imageModels`. + * - `structured-output` - reliable JSON/object output (`Output.object`). + * - `streaming` - token streaming for `streamText`. + */ +export const AI_MODEL_CAPABILITIES = [ + "text", + "image-input", + "structured-output", + "streaming", +] as const; + +export type AiModelCapability = (typeof AI_MODEL_CAPABILITIES)[number]; + +/** Used when a model entry declares no `capabilities`. */ +export const DEFAULT_AI_MODEL_CAPABILITIES: readonly AiModelCapability[] = [ + "text", +]; + +export const isAiModelCapability = ( + value: string, +): value is AiModelCapability => + (AI_MODEL_CAPABILITIES as readonly string[]).includes(value); + +export const missingCapabilities = ( + required: readonly AiModelCapability[], + available: readonly AiModelCapability[], +): AiModelCapability[] => + required.filter(capability => !available.includes(capability)); diff --git a/packages/vitnode/src/api/lib/ai/decimal.ts b/packages/vitnode/src/api/lib/ai/decimal.ts new file mode 100644 index 000000000..f5f0ddea7 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/decimal.ts @@ -0,0 +1,123 @@ +/** + * Fixed-point decimal money. Every amount is a `bigint` of 10^-12 units, so a + * price of $0.075 per million tokens (7.5e-8 per token) is exact, and summing + * a thousand calls never drifts the way JavaScript floats do. + * + * Amounts cross the API and the database as decimal strings. PostgreSQL stores + * them as `numeric(24, 12)`, the same scale, so a round trip is lossless. + */ + +export const DECIMAL_SCALE = 12; +const SCALE_FACTOR = 10n ** BigInt(DECIMAL_SCALE); + +export type Decimal = bigint; + +const DECIMAL_PATTERN = /^(-)?(\d+)(?:\.(\d+))?$/; + +export class DecimalParseError extends Error { + constructor(value: string) { + super(`"${value}" is not a decimal number.`); + this.name = "DecimalParseError"; + } +} + +/** Parses `"0.0003"`, `"12"` or `"-1.5"`. Digits beyond the scale are rounded half up. */ +export const parseDecimal = (value: string): Decimal => { + const match = DECIMAL_PATTERN.exec(value.trim()); + if (!match) throw new DecimalParseError(value); + const [, sign, whole, fraction = ""] = match; + + const kept = fraction.slice(0, DECIMAL_SCALE).padEnd(DECIMAL_SCALE, "0"); + let units = BigInt(whole) * SCALE_FACTOR + BigInt(kept); + const dropped = fraction.slice(DECIMAL_SCALE); + if (dropped.length > 0 && Number(dropped[0]) >= 5) units += 1n; + + return sign ? -units : units; +}; + +/** `null` in, `null` out - unknown stays unknown. */ +export const parseDecimalOrNull = ( + value: null | string | undefined, +): Decimal | null => + value === null || value === undefined ? null : parseDecimal(value); + +/** + * From a provider-reported JavaScript number. Goes through the shortest string + * that round-trips the float, not through float arithmetic. + */ +export const decimalFromNumber = (value: number): Decimal => { + if (!Number.isFinite(value)) { + throw new DecimalParseError(String(value)); + } + const text = value.toFixed(DECIMAL_SCALE + 2); + + return parseDecimal(text); +}; + +export const decimalFromInteger = (value: bigint | number): Decimal => + BigInt(value) * SCALE_FACTOR; + +/** Canonical string, trailing zeros trimmed: `"0.0003"`, `"12"`. */ +export const formatDecimal = (value: Decimal): string => { + const negative = value < 0n; + const absolute = negative ? -value : value; + const whole = absolute / SCALE_FACTOR; + const fraction = (absolute % SCALE_FACTOR) + .toString() + .padStart(DECIMAL_SCALE, "0") + .replace(/0+$/, ""); + + return `${negative ? "-" : ""}${whole}${fraction ? `.${fraction}` : ""}`; +}; + +export const formatDecimalOrNull = (value: Decimal | null): null | string => + value === null ? null : formatDecimal(value); + +/** `a * b` for two decimals, rounded half up at the scale. */ +export const multiplyDecimal = (a: Decimal, b: Decimal): Decimal => + divideRound(a * b, SCALE_FACTOR); + +/** `amount * count`, where `count` is a whole number such as tokens. */ +export const multiplyByInteger = ( + amount: Decimal, + count: bigint | number, +): Decimal => amount * BigInt(count); + +/** `a / b`, rounded half up at the scale. */ +export const divideDecimal = (a: Decimal, b: Decimal): Decimal => { + if (b === 0n) throw new RangeError("Division by zero."); + + return divideRound(a * SCALE_FACTOR, b); +}; + +export const divideByInteger = ( + amount: Decimal, + count: bigint | number, +): Decimal => { + if (BigInt(count) === 0n) throw new RangeError("Division by zero."); + + return divideRound(amount, BigInt(count)); +}; + +export const sumDecimals = (values: Decimal[]): Decimal => + values.reduce((total, value) => total + value, 0n); + +export const maxDecimal = (a: Decimal, b: Decimal): Decimal => (a > b ? a : b); +export const minDecimal = (a: Decimal, b: Decimal): Decimal => (a < b ? a : b); + +const divideRound = (numerator: bigint, denominator: bigint): bigint => { + const negative = numerator < 0n !== denominator < 0n; + const n = numerator < 0n ? -numerator : numerator; + const d = denominator < 0n ? -denominator : denominator; + const quotient = (n * 2n + d) / (d * 2n); + + return negative ? -quotient : quotient; +}; + +/** Rounds up to the scale's last digit - for reservations, never down. */ +export const ceilDivideDecimal = (a: Decimal, b: Decimal): Decimal => { + if (b <= 0n) throw new RangeError("Division by a non-positive number."); + const numerator = a * SCALE_FACTOR; + + return numerator >= 0n ? (numerator + b - 1n) / b : -(-numerator / b); +}; diff --git a/packages/vitnode/src/api/lib/ai/errors.ts b/packages/vitnode/src/api/lib/ai/errors.ts new file mode 100644 index 000000000..c699dad6d --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/errors.ts @@ -0,0 +1,65 @@ +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +import { HTTPException } from "hono/http-exception"; + +/** + * Stable error codes every AI route answers with. The browser keys its + * messages on `code`, never on the English `message`. + */ +export const AI_ERROR_STATUS = { + AI_NOT_CONFIGURED: 400, + AI_INVALID_INPUT: 400, + AI_INPUT_TOO_LARGE: 413, + AI_DISABLED: 503, + AI_ACTION_DISABLED: 403, + AI_ACTION_UNKNOWN: 404, + AI_UNAUTHORIZED: 403, + AI_MODEL_INCOMPATIBLE: 409, + AI_PRICING_MISSING: 409, + AI_USER_LIMIT_REACHED: 429, + AI_DAILY_LIMIT_REACHED: 429, + AI_RATE_LIMITED: 429, + AI_CONCURRENCY_LIMITED: 429, + AI_BUDGET_EXHAUSTED: 503, + AI_DUPLICATE_REQUEST: 409, + AI_PROVIDER_FAILED: 502, + AI_INVALID_OUTPUT: 502, + AI_TIMEOUT: 504, + AI_CANCELED: 408, +} as const satisfies Record; + +export type AiErrorCode = keyof typeof AI_ERROR_STATUS; + +export const AI_ERROR_CODES = Object.keys(AI_ERROR_STATUS) as AiErrorCode[]; + +export interface AiErrorBody { + code: AiErrorCode; + message: string; + /** When a limit resets, for limit and budget errors. */ + resetsAt?: string; + /** The run the error belongs to, when one was recorded. */ + runId?: number; +} + +export class AiError extends HTTPException { + constructor( + code: AiErrorCode, + message: string, + extra: Pick = {}, + ) { + const status = AI_ERROR_STATUS[code] as ContentfulStatusCode; + const body: AiErrorBody = { code, message, ...extra }; + super(status, { + message, + res: Response.json(body, { status }), + }); + this.code = code; + this.body = body; + } + + readonly body: AiErrorBody; + readonly code: AiErrorCode; +} + +export const isAiError = (error: unknown): error is AiError => + error instanceof AiError; diff --git a/packages/vitnode/src/api/lib/ai/html-structure.ts b/packages/vitnode/src/api/lib/ai/html-structure.ts new file mode 100644 index 000000000..58b7184c2 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/html-structure.ts @@ -0,0 +1,75 @@ +/** + * The structural skeleton of an HTML fragment: every tag, in order, with the + * attributes a translation must never touch. Text and translatable attributes + * (`alt`, `title`, `aria-label`, `placeholder`) are left out. + * + * A rich-text translation is accepted only when its skeleton equals the + * source's - a prompt asking the model to "preserve HTML" is not a guarantee. + */ + +const TAG_PATTERN = /<\s*(\/?)\s*([a-zA-Z][\w:-]*)([^>]*?)(\/?)\s*>/g; +const ATTRIBUTE_PATTERN = + /([^\s"'=<>`/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; + +const TRANSLATABLE_ATTRIBUTES = new Set([ + "alt", + "aria-label", + "placeholder", + "title", +]); + +const normalizeAttributes = (raw: string): string => + [...raw.matchAll(ATTRIBUTE_PATTERN)] + .map(match => { + const name = match[1].toLowerCase(); + const value = match[2] ?? match[3] ?? match[4] ?? ""; + + return { name, value }; + }) + .filter(({ name }) => !TRANSLATABLE_ATTRIBUTES.has(name)) + .sort((a, b) => (a.name < b.name ? -1 : 1)) + .map(({ name, value }) => `${name}=${JSON.stringify(value)}`) + .join(" "); + +export const htmlSkeleton = (html: string): string[] => + [...html.replace(//g, "").matchAll(TAG_PATTERN)].map(match => { + const closing = match[1] === "/"; + const name = match[2].toLowerCase(); + if (closing) return ``; + const attributes = normalizeAttributes(match[3]); + + return attributes ? `<${name} ${attributes}>` : `<${name}>`; + }); + +/** The first difference between two skeletons, or `null` when they match. */ +export const htmlStructureDifference = ( + source: string, + translated: string, +): null | string => { + const a = htmlSkeleton(source); + const b = htmlSkeleton(translated); + const length = Math.max(a.length, b.length); + for (let index = 0; index < length; index++) { + if (a[index] !== b[index]) { + return `expected ${a[index] ?? "nothing"} but got ${b[index] ?? "nothing"} at tag ${index + 1}`; + } + } + + return null; +}; + +export class HtmlStructureError extends Error { + constructor(difference: string) { + super(`The translated HTML changed the document structure: ${difference}.`); + this.name = "HtmlStructureError"; + } +} + +/** Throws when the translation changed tags, links or attributes. */ +export const assertSameHtmlStructure = ( + source: string, + translated: string, +): void => { + const difference = htmlStructureDifference(source, translated); + if (difference) throw new HtmlStructureError(difference); +}; diff --git a/packages/vitnode/src/api/lib/ai/ledger.ts b/packages/vitnode/src/api/lib/ai/ledger.ts new file mode 100644 index 000000000..108f38ddb --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/ledger.ts @@ -0,0 +1,176 @@ +import type { AiActorType, AiResourceRef } from "./action"; +import type { Decimal } from "./decimal"; +import type { AiErrorCode } from "./errors"; +import type { AiPricing } from "./pricing"; +import type { AiCost, AiUsage } from "./usage-cost"; + +import { divideDecimal, multiplyDecimal, parseDecimal } from "./decimal"; + +/** + * Point conversion, version 1: 1 AI point = 0.001 USD of chargeable API cost. + * Points are an internal usage unit, not a currency anyone buys. Changing the + * rate means a new version - stored on every run - never an edit of this one. + */ +export const AI_POINTS_CONVERSION = { + 1: { usdPerPoint: "0.001" }, +} as const; +export const AI_POINTS_CONVERSION_VERSION = 1; + +export const usdToPoints = ( + usd: Decimal, + version: keyof typeof AI_POINTS_CONVERSION = AI_POINTS_CONVERSION_VERSION, +): Decimal => + divideDecimal(usd, parseDecimal(AI_POINTS_CONVERSION[version].usdPerPoint)); + +export const pointsToUsd = ( + points: Decimal, + version: keyof typeof AI_POINTS_CONVERSION = AI_POINTS_CONVERSION_VERSION, +): Decimal => + multiplyDecimal( + points, + parseDecimal(AI_POINTS_CONVERSION[version].usdPerPoint), + ); + +export interface AiSettingsSnapshot { + altBatchSize: number; + altEnabled: boolean; + altLanguages: null | string[]; + defaultMonthlyPoints: Decimal; + enabled: boolean; + historyRetentionDays: number; + monthlyBudgetUsd: Decimal | null; + systemConcurrency: number; + systemMonthlyBudgetUsd: Decimal | null; + /** The site's time zone - the default language's - for calendar periods. */ + timeZone: string; + userConcurrency: number; + userRequestsPerMinute: number; +} + +export interface AiActionSettings { + dailyLimit: null | number; + enabled: boolean; + fallbackModelId: null | string; + instructions: null | string; + maxInputCharacters: null | number; + maxOutputTokens: null | number; + maxRetries: null | number; + maxSteps: null | number; + modelId: null | string; + timeoutMs: null | number; +} + +/** Admin-managed pricing rows that are active for one model. */ +export interface AiStoredPricing { + manual: null | { id: number; pricing: AiPricing }; + sync: null | { id: number; pricing: AiPricing }; +} + +export interface AiUserPolicy { + /** Largest daily limit any granting role gives; `null` is no limit. */ + dailyLimit: null | number; + granted: boolean; + /** Monthly points; `null` is unlimited. */ + monthlyPoints: Decimal | null; +} + +export interface AiBudgetRequest { + /** USD upper bound of the whole run; `null` when no pricing can bound it. */ + usd: Decimal | null; +} + +export interface AiReservationRequest { + actionKey: string; + actorType: AiActorType; + daily: null | { limit: null | number; permissionKey: string }; + globalMonthlyUsd: Decimal | null; + idempotencyKey: null | string; + leaseMs: number; + model: { modelId: string; provider: string; providerModelId: string }; + now: Date; + pluginId: string; + promptVersion: number; + resource: AiResourceRef | undefined; + sourceFingerprint: null | string; + systemConcurrency: number; + systemMonthlyUsd: Decimal | null; + timeZone: string; + usd: Decimal | null; + userConcurrency: number; + userId: null | number; + userMonthlyPoints: Decimal | null; + userRequestsPerMinute: number; +} + +export type AiReservationResult = + | { + code: AiErrorCode; + ok: false; + resetsAt?: Date; + /** An earlier run with the same idempotency key. */ + runId?: number; + } + | { ok: true; runId: number }; + +export interface AiCallStart { + attempt: number; + modelId: string; + provider: string; + providerModelId: string; + runId: number; + startedAt: Date; +} + +export interface AiCallFinish { + cost: AiCost; + errorCode: null | string; + finishedAt: Date; + images: number; + pricingSnapshot: AiPricing | null; + providerModelId: null | string; + providerRequestId: null | string; + status: "failed" | "succeeded"; + usage: AiUsage; +} + +export interface AiSettlement { + /** A valid result reached the user. Only then are personal points charged. */ + delivered: boolean; + errorCode: null | string; + finishedAt: Date; + status: "canceled" | "failed" | "succeeded" | "uncertain"; +} + +export interface AiSettlementResult { + /** `false` when the run had already been settled - nothing changed. */ + applied: boolean; + chargedPoints: Decimal; + chargedUsd: Decimal; + costKnown: boolean; +} + +/** + * Where AI money and history live. The Postgres ledger is the real one; the + * memory ledger is a test seam with the same rules (`budget.ts`). + */ +export interface AiLedger { + /** Records the start of a provider call before it is made - a crash leaves it `uncertain`. */ + beginCall: (call: AiCallStart) => Promise; + finishCall: (callId: number, call: AiCallFinish) => Promise; + loadActionSettings: (actionKey: string) => Promise; + loadPricing: (modelId: string) => Promise; + loadSettings: () => Promise; + markRunning: (runId: number, leaseExpiresAt: Date) => Promise; + /** Atomically checks and holds every applicable budget and counter. */ + reserve: (request: AiReservationRequest) => Promise; + resolveUserPolicy: (args: { + defaultGranted: boolean; + permissionKey: string; + userId: number; + }) => Promise; + /** Idempotent: a second settlement of the same run changes nothing. */ + settle: ( + runId: number, + settlement: AiSettlement, + ) => Promise; +} diff --git a/packages/vitnode/src/api/lib/ai/memory-ledger.ts b/packages/vitnode/src/api/lib/ai/memory-ledger.ts new file mode 100644 index 000000000..ce34f43d9 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/memory-ledger.ts @@ -0,0 +1,333 @@ +import type { AiBudgetRowState, AiBudgetUnit } from "./budget"; +import type { Decimal } from "./decimal"; +import type { + AiActionSettings, + AiCallFinish, + AiCallStart, + AiLedger, + AiReservationRequest, + AiReservationResult, + AiSettingsSnapshot, + AiSettlement, + AiSettlementResult, + AiStoredPricing, + AiUserPolicy, +} from "./ledger"; + +import { + findExceededBudget, + planBudgetItems, + settlementCharge, +} from "./budget"; +import { maxDecimal, parseDecimalOrNull, sumDecimals } from "./decimal"; +import { DEFAULT_AI_SETTINGS } from "./postgres-ledger"; + +export interface MemoryAiRun { + actionKey: string; + actorType: "system" | "user"; + chargedPoints: Decimal | null; + chargedUsd: Decimal | null; + createdAt: Date; + errorCode: null | string; + id: number; + idempotencyKey: null | string; + leaseExpiresAt: Date | null; + reservedUsd: Decimal; + settled: boolean; + status: string; + userId: null | number; +} + +export interface MemoryAiCall extends AiCallStart { + finish?: AiCallFinish; + id: number; +} + +/** + * An in-process ledger with the same budget rules as the Postgres one. It is a + * seam for route and runner tests - it proves behaviour, not concurrency. The + * concurrency guarantees are proven against PostgreSQL itself. + */ +export class MemoryAiLedger implements AiLedger { + constructor({ + actionSettings = {}, + policy = () => ({ dailyLimit: null, granted: true, monthlyPoints: null }), + pricing = {}, + settings = {}, + }: { + actionSettings?: Record>; + policy?: (args: { permissionKey: string; userId: number }) => AiUserPolicy; + pricing?: Record>; + settings?: Partial; + } = {}) { + this.actionSettings = actionSettings; + this.policy = policy; + this.pricing = pricing; + this.settings = { ...DEFAULT_AI_SETTINGS, timeZone: "UTC", ...settings }; + } + + private readonly actionSettings: Record>; + private readonly holds = new Map< + number, + { amount: Decimal; key: string; unit: AiBudgetUnit }[] + >(); + private readonly policy: (args: { + permissionKey: string; + userId: number; + }) => AiUserPolicy; + private readonly pricing: Record>; + readonly budgets = new Map< + string, + AiBudgetRowState & { unit: AiBudgetUnit; unknownCount: number } + >(); + readonly calls: MemoryAiCall[] = []; + readonly runs: MemoryAiRun[] = []; + settings: AiSettingsSnapshot; + + async beginCall(call: AiCallStart): Promise { + const id = this.calls.length + 1; + this.calls.push({ ...call, id }); + + return Promise.resolve(id); + } + + async finishCall(callId: number, call: AiCallFinish): Promise { + const entry = this.calls.find(item => item.id === callId); + if (entry) entry.finish = call; + + return Promise.resolve(); + } + + async loadActionSettings( + actionKey: string, + ): Promise { + const partial = this.actionSettings[actionKey]; + if (!partial) return Promise.resolve(null); + + return Promise.resolve({ + dailyLimit: null, + enabled: true, + fallbackModelId: null, + instructions: null, + maxInputCharacters: null, + maxOutputTokens: null, + maxRetries: null, + maxSteps: null, + modelId: null, + timeoutMs: null, + ...partial, + }); + } + + async loadPricing(modelId: string): Promise { + return Promise.resolve({ + manual: null, + sync: null, + ...this.pricing[modelId], + }); + } + + async loadSettings(): Promise { + return Promise.resolve(this.settings); + } + + async markRunning(runId: number, leaseExpiresAt: Date): Promise { + const run = this.runs.find(item => item.id === runId); + if (run?.status === "reserved") { + run.status = "running"; + run.leaseExpiresAt = leaseExpiresAt; + } + + return Promise.resolve(); + } + + async reserve(request: AiReservationRequest): Promise { + if (request.idempotencyKey) { + const existing = this.runs.find( + run => + run.actorType === request.actorType && + run.userId === request.userId && + run.idempotencyKey === request.idempotencyKey, + ); + if (existing) { + return Promise.resolve({ + code: "AI_DUPLICATE_REQUEST", + ok: false, + runId: existing.id, + }); + } + } + + const items = planBudgetItems(request); + const rowKey = (scopeKey: string, start: Date) => + `${scopeKey}@${start.toISOString()}`; + const rows = new Map(); + for (const item of items) { + const key = rowKey(item.scopeKey, item.period.start); + const row = this.budgets.get(key) ?? { + limitAmount: item.limit, + periodEnd: item.period.end, + reservedAmount: 0n, + scopeKey: item.scopeKey, + spentAmount: 0n, + unit: item.unit, + unknownCount: 0, + }; + this.budgets.set(key, row); + rows.set(item.scopeKey, row); + } + + const mine = this.runs.filter(run => + request.actorType === "system" + ? run.actorType === "system" + : run.actorType === "user" && run.userId === request.userId, + ); + const active = mine.filter( + run => + (run.status === "reserved" || run.status === "running") && + (run.leaseExpiresAt?.getTime() ?? 0) > request.now.getTime(), + ).length; + const concurrency = + request.actorType === "system" + ? request.systemConcurrency + : request.userConcurrency; + if (active >= concurrency) { + return Promise.resolve({ code: "AI_CONCURRENCY_LIMITED", ok: false }); + } + if (request.actorType === "user") { + const since = request.now.getTime() - 60_000; + const recent = mine.filter(run => run.createdAt.getTime() > since).length; + if (recent >= request.userRequestsPerMinute) { + return Promise.resolve({ + code: "AI_RATE_LIMITED", + ok: false, + resetsAt: new Date(request.now.getTime() + 60_000), + }); + } + } + + const exceeded = findExceededBudget(items, rows, { + priced: request.usd !== null, + }); + if (exceeded) { + return Promise.resolve({ + code: exceeded.code, + ok: false, + resetsAt: exceeded.resetsAt, + }); + } + + const id = this.runs.length + 1; + this.runs.push({ + actionKey: request.actionKey, + actorType: request.actorType, + chargedPoints: null, + chargedUsd: null, + createdAt: request.now, + errorCode: null, + id, + idempotencyKey: request.idempotencyKey, + leaseExpiresAt: new Date(request.now.getTime() + request.leaseMs), + reservedUsd: request.usd ?? 0n, + settled: false, + status: "reserved", + userId: request.userId, + }); + const holds = items.map(item => { + const row = rows.get(item.scopeKey); + if (row) { + row.limitAmount = item.limit; + row.reservedAmount += item.amount; + } + + return { + amount: item.amount, + key: rowKey(item.scopeKey, item.period.start), + unit: item.unit, + }; + }); + this.holds.set(id, holds); + + return Promise.resolve({ ok: true, runId: id }); + } + + async resolveUserPolicy({ + permissionKey, + userId, + }: { + defaultGranted: boolean; + permissionKey: string; + userId: number; + }): Promise { + return Promise.resolve(this.policy({ permissionKey, userId })); + } + + async settle( + runId: number, + settlement: AiSettlement, + ): Promise { + const run = this.runs.find(item => item.id === runId); + if (!run || run.settled) { + return Promise.resolve({ + applied: false, + chargedPoints: run?.chargedPoints ?? 0n, + chargedUsd: run?.chargedUsd ?? 0n, + costKnown: true, + }); + } + + const costs = this.calls + .filter(call => call.runId === runId) + .map(call => parseDecimalOrNull(call.finish?.cost.amountUsd ?? null)); + const known = costs.every(cost => cost !== null); + const knownUsd = sumDecimals(costs.filter((c): c is Decimal => c !== null)); + const chargedUsd = known ? knownUsd : maxDecimal(run.reservedUsd, knownUsd); + + let chargedPoints = 0n; + for (const hold of this.holds.get(runId) ?? []) { + const row = this.budgets.get(hold.key); + if (!row) continue; + const charge = settlementCharge({ + chargedUsd, + delivered: settlement.delivered, + unit: hold.unit, + }); + if (hold.unit === "points") chargedPoints = charge; + row.reservedAmount -= hold.amount; + row.spentAmount += charge; + if (!known && hold.unit === "usd") row.unknownCount += 1; + } + + run.settled = true; + run.status = settlement.status; + run.errorCode = settlement.errorCode; + run.chargedUsd = chargedUsd; + run.chargedPoints = chargedPoints; + run.leaseExpiresAt = null; + + return Promise.resolve({ + applied: true, + chargedPoints, + chargedUsd, + costKnown: known, + }); + } + + /** Points a user has spent this month, for assertions. */ + spentPoints(userId: number): Decimal { + return sumDecimals( + [...this.budgets.values()] + .filter(row => row.scopeKey === `user:${userId}`) + .map(row => row.spentAmount), + ); + } + + /** USD the site has spent this month, for assertions. */ + spentUsd(scopeKey = "global"): Decimal { + return sumDecimals( + [...this.budgets.values()] + .filter(row => row.scopeKey === scopeKey) + .map(row => row.spentAmount), + ); + } +} diff --git a/packages/vitnode/src/api/lib/ai/periods.ts b/packages/vitnode/src/api/lib/ai/periods.ts new file mode 100644 index 000000000..9ffe64aad --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/periods.ts @@ -0,0 +1,128 @@ +/** + * Calendar periods in the site's time zone, stored as UTC instants. A monthly + * allowance resets at local midnight on the 1st, wherever the server runs. + */ + +export type AiPeriodKind = "day" | "month"; + +export interface AiPeriod { + end: Date; + start: Date; +} + +interface LocalParts { + day: number; + hour: number; + minute: number; + month: number; + second: number; + year: number; +} + +const formatterCache = new Map(); + +const formatterFor = (timeZone: string): Intl.DateTimeFormat => { + let formatter = formatterCache.get(timeZone); + if (!formatter) { + formatter = new Intl.DateTimeFormat("en-US", { + day: "numeric", + hour: "numeric", + hourCycle: "h23", + minute: "numeric", + month: "numeric", + second: "numeric", + timeZone, + year: "numeric", + }); + formatterCache.set(timeZone, formatter); + } + + return formatter; +}; + +const localParts = (date: Date, timeZone: string): LocalParts => { + const parts = Object.fromEntries( + formatterFor(timeZone) + .formatToParts(date) + .filter(part => part.type !== "literal") + .map(part => [part.type, Number(part.value)]), + ); + + return { + day: parts.day, + hour: parts.hour, + minute: parts.minute, + month: parts.month, + second: parts.second, + year: parts.year, + }; +}; + +/** Milliseconds the zone is ahead of UTC at `date`. */ +const offsetAt = (date: Date, timeZone: string): number => { + const parts = localParts(date, timeZone); + const asUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second, + ); + + return asUtc - Math.floor(date.getTime() / 1000) * 1000; +}; + +/** The UTC instant of a local wall-clock midnight. Handles DST shifts. */ +const zonedMidnight = ( + year: number, + month: number, + day: number, + timeZone: string, +): Date => { + const guess = Date.UTC(year, month - 1, day); + const first = guess - offsetAt(new Date(guess), timeZone); + + return new Date(guess - offsetAt(new Date(first), timeZone)); +}; + +export const isValidTimeZone = (timeZone: string): boolean => { + try { + formatterFor(timeZone); + + return true; + } catch { + return false; + } +}; + +export const periodContaining = ( + now: Date, + kind: AiPeriodKind, + timeZone = "UTC", +): AiPeriod => { + const zone = isValidTimeZone(timeZone) ? timeZone : "UTC"; + const { day, month, year } = localParts(now, zone); + + if (kind === "day") { + const next = new Date(Date.UTC(year, month - 1, day + 1)); + + return { + start: zonedMidnight(year, month, day, zone), + end: zonedMidnight( + next.getUTCFullYear(), + next.getUTCMonth() + 1, + next.getUTCDate(), + zone, + ), + }; + } + + const nextMonth = month === 12 ? 1 : month + 1; + const nextYear = month === 12 ? year + 1 : year; + + return { + start: zonedMidnight(year, month, 1, zone), + end: zonedMidnight(nextYear, nextMonth, 1, zone), + }; +}; diff --git a/packages/vitnode/src/api/lib/ai/postgres-ledger.integration.test.ts b/packages/vitnode/src/api/lib/ai/postgres-ledger.integration.test.ts new file mode 100644 index 000000000..0ed1de79d --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/postgres-ledger.integration.test.ts @@ -0,0 +1,336 @@ +// @vitest-environment node +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +import type { TestDatabaseHandle } from "@/tests/postgres"; + +import * as aiTables from "@/database/ai"; +import { + core_ai_budget_periods, + core_ai_role_permissions, + core_ai_role_policies, + core_ai_runs, + core_ai_user_overrides, +} from "@/database/ai"; +import * as filesTables from "@/database/files"; +import * as languagesTables from "@/database/languages"; +import { core_languages } from "@/database/languages"; +import * as rolesTables from "@/database/roles"; +import { core_roles } from "@/database/roles"; +import * as usersTables from "@/database/users"; +import { core_users, core_users_secondary_roles } from "@/database/users"; +import { createTestDatabase, describePostgres } from "@/tests/postgres"; + +import type { AiReservationRequest } from "./ledger"; + +import { formatDecimal, parseDecimal } from "./decimal"; +import { PostgresAiLedger } from "./postgres-ledger"; + +/** + * Proves the reservation guarantees against PostgreSQL itself: row locks, + * transaction isolation and unique indexes are what make them hold, so a fake + * could only ever restate the intent. + */ +describePostgres("PostgresAiLedger (real PostgreSQL)", () => { + let database: TestDatabaseHandle; + let ledger: PostgresAiLedger; + let month = 0; + + /** A month of its own per test, so budgets never leak between tests. */ + const freshNow = () => { + month += 1; + + return new Date(Date.UTC(2090, month, 15, 12)); + }; + + const request = ( + overrides: Partial = {}, + ): AiReservationRequest => ({ + actionKey: "@acme/notes:summary.generate", + actorType: "user", + daily: null, + globalMonthlyUsd: null, + idempotencyKey: null, + leaseMs: 60_000, + model: { modelId: "default", provider: "mock", providerModelId: "mock-1" }, + now: new Date(), + pluginId: "@acme/notes", + promptVersion: 1, + resource: undefined, + sourceFingerprint: null, + systemConcurrency: 100, + systemMonthlyUsd: null, + timeZone: "UTC", + usd: parseDecimal("0.03"), + userConcurrency: 100, + userId: 1, + userMonthlyPoints: null, + userRequestsPerMinute: 1_000, + ...overrides, + }); + + const settle = async (runId: number, delivered = true) => + await ledger.settle(runId, { + delivered, + errorCode: null, + finishedAt: new Date(), + status: delivered ? "succeeded" : "failed", + }); + + const finishCall = async (runId: number, costUsd: null | string) => { + const callId = await ledger.beginCall({ + attempt: 1, + modelId: "default", + provider: "mock", + providerModelId: "mock-1", + runId, + startedAt: new Date(), + }); + await ledger.finishCall(callId, { + cost: + costUsd === null + ? { amountUsd: null, reason: "no pricing", source: "unknown" } + : { amountUsd: costUsd, pricingVersion: "test", source: "pricing" }, + errorCode: null, + finishedAt: new Date(), + images: 0, + pricingSnapshot: null, + providerModelId: "mock-1", + providerRequestId: "req", + status: "succeeded", + usage: { + cacheReadTokens: 0, + cacheWriteTokens: 0, + inputTokens: 10, + outputTokens: 5, + reasoningTokens: null, + }, + }); + }; + + const budgetRow = async (scopeKey: string, now: Date) => { + const rows = await database.db + .select() + .from(core_ai_budget_periods) + .where(eq(core_ai_budget_periods.scopeKey, scopeKey)); + + return rows.find( + row => + row.periodStart.getTime() <= now.getTime() && + row.periodEnd.getTime() > now.getTime(), + ); + }; + + beforeAll(async () => { + database = await createTestDatabase({ + ...aiTables, + ...filesTables, + ...languagesTables, + ...rolesTables, + ...usersTables, + }); + ledger = new PostgresAiLedger(database.db); + await database.db.insert(core_languages).values({ + code: "en", + default: true, + name: "English", + }); + await database.db.insert(core_roles).values([ + { id: 1, updatedAt: new Date() }, + { id: 2, updatedAt: new Date() }, + { id: 3, root: true, updatedAt: new Date() }, + ]); + await database.db.insert(core_users).values( + [1, 2, 3, 4, 5].map(id => ({ + avatarColor: "000000", + email: `user${id}@test.test`, + id, + ipAddress: "127.0.0.1", + name: `user${id}`, + nameCode: `user${id}`, + roleId: id === 5 ? 3 : 1, + })), + ); + }, 60_000); + + afterAll(async () => { + await database?.drop(); + }); + + it("lets concurrent runs take only the global budget that exists", async () => { + const now = freshNow(); + const attempts = await Promise.all( + Array.from({ length: 12 }, async () => + await ledger.reserve( + request({ globalMonthlyUsd: parseDecimal("0.1"), now }), + ), + ), + ); + + expect(attempts.filter(result => result.ok)).toHaveLength(3); + expect( + attempts.filter(result => !result.ok).map(result => !result.ok && result.code), + ).toEqual(Array(9).fill("AI_BUDGET_EXHAUSTED")); + const global = await budgetRow("global", now); + expect(global?.reservedAmount).toBe("0.090000000000"); + }); + + it("lets concurrent runs of one user take only the points they have", async () => { + const now = freshNow(); + const attempts = await Promise.all( + Array.from({ length: 8 }, async () => + await ledger.reserve( + request({ now, userId: 2, userMonthlyPoints: parseDecimal("50") }), + ), + ), + ); + + expect(attempts.filter(result => result.ok)).toHaveLength(1); + expect( + attempts.find(result => !result.ok && result.code === "AI_USER_LIMIT_REACHED"), + ).toBeDefined(); + }); + + it("enforces per-user concurrency under a race", async () => { + const now = freshNow(); + const attempts = await Promise.all( + Array.from({ length: 6 }, async () => + await ledger.reserve(request({ now, userConcurrency: 2, userId: 3 })), + ), + ); + + expect(attempts.filter(result => result.ok)).toHaveLength(2); + }); + + it("settles a run exactly once, however many settle at the same time", async () => { + const now = freshNow(); + const reserved = await ledger.reserve(request({ now, userId: 4 })); + if (!reserved.ok) throw new Error(reserved.code); + await finishCall(reserved.runId, "0.012"); + + const results = await Promise.all( + Array.from({ length: 5 }, async () => await settle(reserved.runId)), + ); + + expect(results.filter(result => result.applied)).toHaveLength(1); + const user = await budgetRow("user:4", now); + expect(user?.spentAmount).toBe("12.000000000000"); + expect(user?.reservedAmount).toBe("0.000000000000"); + const global = await budgetRow("global", now); + expect(global?.spentAmount).toBe("0.012000000000"); + }); + + it("charges the site the whole hold for an unknown cost, and the user nothing for a failure", async () => { + const now = freshNow(); + const reserved = await ledger.reserve(request({ now, userId: 4 })); + if (!reserved.ok) throw new Error(reserved.code); + await finishCall(reserved.runId, null); + + const result = await settle(reserved.runId, false); + + expect(result).toMatchObject({ applied: true, costKnown: false }); + expect(formatDecimal(result.chargedUsd)).toBe("0.03"); + const global = await budgetRow("global", now); + expect(global).toMatchObject({ spentAmount: "0.030000000000", unknownCount: 1 }); + const user = await budgetRow("user:4", now); + expect(user?.spentAmount).toBe("0.000000000000"); + }); + + it("runs a duplicated idempotency key once, even when both race", async () => { + const now = freshNow(); + const results = await Promise.all( + Array.from({ length: 4 }, async () => + await ledger.reserve(request({ idempotencyKey: "same", now, userId: 4 })), + ), + ); + + expect(results.filter(result => result.ok)).toHaveLength(1); + expect( + results.filter(result => !result.ok && result.code === "AI_DUPLICATE_REQUEST"), + ).toHaveLength(3); + }); + + it("never deadlocks when users and the system reserve at once", async () => { + const now = freshNow(); + const results = await Promise.all( + Array.from({ length: 30 }, async (_, index) => + await ledger.reserve( + request({ + actorType: index % 3 === 0 ? "system" : "user", + daily: + index % 3 === 0 + ? null + : { limit: 100, permissionKey: "@acme/notes:summary" }, + now, + userId: index % 3 === 0 ? null : (index % 2) + 1, + }), + ), + ), + ); + + expect(results.every(result => result.ok)).toBe(true); + }); + + it("resolves policies from real role tables: largest allowance, override wins", async () => { + await database.db.insert(core_users_secondary_roles).values({ roleId: 2, userId: 1 }); + await database.db.insert(core_ai_role_policies).values([ + { monthlyPoints: "100", roleId: 1 }, + { monthlyPoints: "400", roleId: 2 }, + ]); + await database.db.insert(core_ai_role_permissions).values([ + { dailyLimit: 5, permission: "@acme/notes:summary", roleId: 1 }, + { dailyLimit: 9, permission: "@acme/notes:summary", roleId: 2 }, + ]); + + const policy = await ledger.resolveUserPolicy({ + defaultGranted: false, + permissionKey: "@acme/notes:summary", + userId: 1, + }); + + expect(policy).toEqual({ + dailyLimit: 9, + granted: true, + monthlyPoints: parseDecimal("400"), + }); + + await database.db + .insert(core_ai_user_overrides) + .values({ monthlyPoints: "7", userId: 1 }); + const overridden = await ledger.resolveUserPolicy({ + defaultGranted: false, + permissionKey: "@acme/notes:summary", + userId: 1, + }); + expect(overridden.monthlyPoints).toBe(parseDecimal("7")); + + const root = await ledger.resolveUserPolicy({ + defaultGranted: false, + permissionKey: "@acme/notes:other", + userId: 5, + }); + expect(root).toMatchObject({ granted: true, monthlyPoints: null }); + }); + + it("keeps consumed usage when a user's roles change", async () => { + const now = freshNow(); + const reserved = await ledger.reserve( + request({ now, userId: 3, userMonthlyPoints: parseDecimal("100") }), + ); + if (!reserved.ok) throw new Error(reserved.code); + await finishCall(reserved.runId, "0.05"); + await settle(reserved.runId); + + // A smaller allowance after a role change: the 50 points already spent stay spent. + const next = await ledger.reserve( + request({ now, userId: 3, usd: parseDecimal("0.02"), userMonthlyPoints: parseDecimal("60") }), + ); + + expect(next).toMatchObject({ code: "AI_USER_LIMIT_REACHED", ok: false }); + const [run] = await database.db + .select({ chargedPoints: core_ai_runs.chargedPoints }) + .from(core_ai_runs) + .where(eq(core_ai_runs.id, reserved.runId)); + expect(run.chargedPoints).toBe("50.000000000000"); + }); +}); diff --git a/packages/vitnode/src/api/lib/ai/postgres-ledger.ts b/packages/vitnode/src/api/lib/ai/postgres-ledger.ts new file mode 100644 index 000000000..8eee070f5 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/postgres-ledger.ts @@ -0,0 +1,716 @@ +import { and, asc, count, eq, gt, inArray, or, sql } from "drizzle-orm"; + +import type { EnvVariablesVitNode } from "../../middlewares/global.middleware"; +import type { AiBudgetRowState } from "./budget"; +import type { Decimal } from "./decimal"; +import type { + AiActionSettings, + AiCallFinish, + AiCallStart, + AiLedger, + AiReservationRequest, + AiReservationResult, + AiSettingsSnapshot, + AiSettlement, + AiSettlementResult, + AiStoredPricing, + AiUserPolicy, +} from "./ledger"; + +import { + core_ai_action_settings, + core_ai_budget_periods, + core_ai_calls, + core_ai_pricing, + core_ai_reservations, + core_ai_role_permissions, + core_ai_role_policies, + core_ai_runs, + core_ai_settings, + core_ai_user_overrides, +} from "../../../database/ai"; +import { core_languages } from "../../../database/languages"; +import { core_roles } from "../../../database/roles"; +import { + core_users, + core_users_secondary_roles, +} from "../../../database/users"; +import { PG_ERROR_CODES, pgErrorCode } from "../../../lib/api/pg-error"; +import { + findExceededBudget, + planBudgetItems, + settlementCharge, +} from "./budget"; +import { + formatDecimal, + formatDecimalOrNull, + maxDecimal, + parseDecimal, + parseDecimalOrNull, + sumDecimals, +} from "./decimal"; +import { AI_POINTS_CONVERSION_VERSION, usdToPoints } from "./ledger"; + +export type AiDatabase = EnvVariablesVitNode["db"]; +type Transaction = Parameters[0]>[0]; + +const ACTIVE_RUN_STATUSES = ["reserved", "running"] as const; + +export const DEFAULT_AI_SETTINGS: Omit = { + altBatchSize: 10, + altEnabled: false, + altLanguages: null, + defaultMonthlyPoints: 0n, + enabled: true, + historyRetentionDays: 365, + monthlyBudgetUsd: null, + systemConcurrency: 2, + systemMonthlyBudgetUsd: null, + userConcurrency: 2, + userRequestsPerMinute: 10, +}; + +export class PostgresAiLedger implements AiLedger { + constructor(db: AiDatabase) { + this.db = db; + } + + private readonly db: AiDatabase; + + private async checkThrottles( + tx: Transaction, + request: AiReservationRequest, + ): Promise { + const actorFilter = + request.actorType === "system" + ? eq(core_ai_runs.actorType, "system") + : and( + eq(core_ai_runs.actorType, "user"), + eq(core_ai_runs.userId, request.userId ?? -1), + ); + + const [active] = await tx + .select({ total: count() }) + .from(core_ai_runs) + .where( + and( + actorFilter, + inArray(core_ai_runs.status, [...ACTIVE_RUN_STATUSES]), + gt(core_ai_runs.leaseExpiresAt, request.now), + ), + ); + const concurrency = + request.actorType === "system" + ? request.systemConcurrency + : request.userConcurrency; + if (active.total >= concurrency) { + return { code: "AI_CONCURRENCY_LIMITED", ok: false }; + } + + if (request.actorType === "user") { + const since = new Date(request.now.getTime() - 60_000); + const [recent] = await tx + .select({ total: count() }) + .from(core_ai_runs) + .where(and(actorFilter, gt(core_ai_runs.createdAt, since))); + if (recent.total >= request.userRequestsPerMinute) { + return { + code: "AI_RATE_LIMITED", + ok: false, + resetsAt: new Date(request.now.getTime() + 60_000), + }; + } + } + + return null; + } + + private async findIdempotentRun( + tx: AiDatabase | Transaction, + request: AiReservationRequest, + ): Promise { + if (!request.idempotencyKey) return null; + const [existing] = await tx + .select({ id: core_ai_runs.id }) + .from(core_ai_runs) + .where( + and( + eq(core_ai_runs.actorType, request.actorType), + request.userId === null + ? sql`${core_ai_runs.userId} IS NULL` + : eq(core_ai_runs.userId, request.userId), + eq(core_ai_runs.idempotencyKey, request.idempotencyKey), + ), + ) + .limit(1); + + return existing?.id ?? null; + } + + private async reserveOnce( + request: AiReservationRequest, + ): Promise { + const items = planBudgetItems(request); + + return await this.db.transaction(async tx => { + if (request.idempotencyKey) { + const existing = await this.findIdempotentRun(tx, request); + if (existing) { + return { code: "AI_DUPLICATE_REQUEST", ok: false, runId: existing }; + } + } + + for (const item of items) { + await tx + .insert(core_ai_budget_periods) + .values({ + limitAmount: formatDecimalOrNull(item.limit), + periodEnd: item.period.end, + periodStart: item.period.start, + scopeKey: item.scopeKey, + unit: item.unit, + }) + .onConflictDoNothing(); + } + + const locked = await tx + .select() + .from(core_ai_budget_periods) + .where( + or( + ...items.map(item => + and( + eq(core_ai_budget_periods.scopeKey, item.scopeKey), + eq(core_ai_budget_periods.periodStart, item.period.start), + ), + ), + ), + ) + .orderBy(asc(core_ai_budget_periods.scopeKey)) + .for("update"); + + const rows = new Map( + locked.map(row => [ + row.scopeKey, + { + id: row.id, + limitAmount: parseDecimalOrNull(row.limitAmount), + periodEnd: row.periodEnd, + reservedAmount: parseDecimal(row.reservedAmount), + scopeKey: row.scopeKey, + spentAmount: parseDecimal(row.spentAmount), + }, + ]), + ); + + // Rate and concurrency are checked under the actor's own budget lock, so + // two requests of one user are serialised here and cannot both slip in. + const rejected = await this.checkThrottles(tx, request); + if (rejected) return rejected; + + const exceeded = findExceededBudget(items, rows, { + priced: request.usd !== null, + }); + if (exceeded) { + return { code: exceeded.code, ok: false, resetsAt: exceeded.resetsAt }; + } + + const usd = request.usd ?? 0n; + const [run] = await tx + .insert(core_ai_runs) + .values({ + actionKey: request.actionKey, + actorType: request.actorType, + idempotencyKey: request.idempotencyKey, + leaseExpiresAt: new Date(request.now.getTime() + request.leaseMs), + modelId: request.model.modelId, + pluginId: request.pluginId, + pointsConversionVersion: AI_POINTS_CONVERSION_VERSION, + promptVersion: request.promptVersion, + provider: request.model.provider, + providerModelId: request.model.providerModelId, + reservedPoints: + request.actorType === "user" + ? formatDecimal(usdToPoints(usd)) + : "0", + reservedUsd: formatDecimal(usd), + resourceId: + request.resource?.id === null || request.resource?.id === undefined + ? null + : String(request.resource.id), + resourceType: request.resource?.type ?? null, + sourceFingerprint: request.sourceFingerprint, + status: "reserved", + userId: request.userId, + createdAt: request.now, + }) + .returning({ id: core_ai_runs.id }); + + for (const item of items) { + const row = rows.get(item.scopeKey); + if (!row) continue; + await tx + .update(core_ai_budget_periods) + .set({ + limitAmount: formatDecimalOrNull(item.limit), + reservedAmount: sql`${core_ai_budget_periods.reservedAmount} + ${formatDecimal(item.amount)}`, + }) + .where(eq(core_ai_budget_periods.id, row.id)); + await tx.insert(core_ai_reservations).values({ + amount: formatDecimal(item.amount), + budgetPeriodId: row.id, + runId: run.id, + }); + } + + return { ok: true, runId: run.id }; + }); + } + + async beginCall(call: AiCallStart): Promise { + const [row] = await this.db + .insert(core_ai_calls) + .values({ + attempt: call.attempt, + costSource: "unknown", + costReason: "in flight", + modelId: call.modelId, + provider: call.provider, + providerModelId: call.providerModelId, + runId: call.runId, + startedAt: call.startedAt, + status: "uncertain", + }) + .returning({ id: core_ai_calls.id }); + + return row.id; + } + + async finishCall(callId: number, call: AiCallFinish): Promise { + await this.db + .update(core_ai_calls) + .set({ + cacheReadTokens: call.usage.cacheReadTokens, + cacheWriteTokens: call.usage.cacheWriteTokens, + costReason: call.cost.source === "unknown" ? call.cost.reason : null, + costSource: call.cost.source, + costUsd: call.cost.amountUsd, + errorCode: call.errorCode, + finishedAt: call.finishedAt, + images: call.images, + inputTokens: call.usage.inputTokens, + outputTokens: call.usage.outputTokens, + pricingSnapshot: call.pricingSnapshot, + pricingVersion: + call.cost.source === "manual" || call.cost.source === "pricing" + ? call.cost.pricingVersion + : null, + providerModelId: call.providerModelId ?? undefined, + providerRequestId: call.providerRequestId, + reasoningTokens: call.usage.reasoningTokens, + status: call.status, + }) + .where(eq(core_ai_calls.id, callId)); + } + + async loadActionSettings( + actionKey: string, + ): Promise { + const [row] = await this.db + .select() + .from(core_ai_action_settings) + .where(eq(core_ai_action_settings.actionKey, actionKey)) + .limit(1); + if (!row) return null; + + return { + dailyLimit: row.dailyLimit, + enabled: row.enabled, + fallbackModelId: row.fallbackModelId, + instructions: row.instructions, + maxInputCharacters: row.maxInputCharacters, + maxOutputTokens: row.maxOutputTokens, + maxRetries: row.maxRetries, + maxSteps: row.maxSteps, + modelId: row.modelId, + timeoutMs: row.timeoutMs, + }; + } + + async loadPricing(modelId: string): Promise { + const rows = await this.db + .select() + .from(core_ai_pricing) + .where( + and( + eq(core_ai_pricing.modelId, modelId), + eq(core_ai_pricing.active, true), + ), + ); + const of = (source: "manual" | "sync") => { + const row = rows.find(entry => entry.source === source); + + return row ? { id: row.id, pricing: row.pricing } : null; + }; + + return { manual: of("manual"), sync: of("sync") }; + } + + async loadSettings(): Promise { + const [[row], [language]] = await Promise.all([ + this.db.select().from(core_ai_settings).where(eq(core_ai_settings.id, 1)), + this.db + .select({ timezone: core_languages.timezone }) + .from(core_languages) + .where(eq(core_languages.default, true)) + .limit(1), + ]); + const timeZone = language?.timezone ?? "UTC"; + if (!row) return { ...DEFAULT_AI_SETTINGS, timeZone }; + + return { + altBatchSize: row.altBatchSize, + altEnabled: row.altEnabled, + altLanguages: row.altLanguages ?? null, + defaultMonthlyPoints: parseDecimal(row.defaultMonthlyPoints), + enabled: row.enabled, + historyRetentionDays: row.historyRetentionDays, + monthlyBudgetUsd: parseDecimalOrNull(row.monthlyBudgetUsd), + systemConcurrency: row.systemConcurrency, + systemMonthlyBudgetUsd: parseDecimalOrNull(row.systemMonthlyBudgetUsd), + timeZone, + userConcurrency: row.userConcurrency, + userRequestsPerMinute: row.userRequestsPerMinute, + }; + } + + async markRunning(runId: number, leaseExpiresAt: Date): Promise { + await this.db + .update(core_ai_runs) + .set({ leaseExpiresAt, startedAt: new Date(), status: "running" }) + .where( + and(eq(core_ai_runs.id, runId), eq(core_ai_runs.status, "reserved")), + ); + } + + /** + * One short transaction: make sure every budget row exists, lock them all in + * `scopeKey` order, check rate, concurrency and every cap, then write the + * run and its holds. Nothing slow happens while the locks are held. + */ + async reserve(request: AiReservationRequest): Promise { + try { + return await this.reserveOnce(request); + } catch (error) { + // Two submissions with one idempotency key raced past the lookup: the + // unique index let exactly one in. The other is a duplicate, not a failure. + if ( + request.idempotencyKey && + pgErrorCode(error) === PG_ERROR_CODES.uniqueViolation + ) { + const existing = await this.findIdempotentRun(this.db, request); + if (existing) { + return { code: "AI_DUPLICATE_REQUEST", ok: false, runId: existing }; + } + } + throw error; + } + } + + async resolveUserPolicy({ + defaultGranted, + permissionKey, + userId, + }: { + defaultGranted: boolean; + permissionKey: string; + userId: number; + }): Promise { + const [user] = await this.db + .select({ roleId: core_users.roleId }) + .from(core_users) + .where(eq(core_users.id, userId)) + .limit(1); + if (!user) return { dailyLimit: null, granted: false, monthlyPoints: 0n }; + + const secondary = await this.db + .select({ roleId: core_users_secondary_roles.roleId }) + .from(core_users_secondary_roles) + .where(eq(core_users_secondary_roles.userId, userId)); + const roleIds = [ + ...new Set([user.roleId, ...secondary.map(r => r.roleId)]), + ]; + + const [roles, permissions, policies, [override], settings] = + await Promise.all([ + this.db + .select({ id: core_roles.id, root: core_roles.root }) + .from(core_roles) + .where(inArray(core_roles.id, roleIds)), + this.db + .select() + .from(core_ai_role_permissions) + .where( + and( + inArray(core_ai_role_permissions.roleId, roleIds), + eq(core_ai_role_permissions.permission, permissionKey), + ), + ), + this.db + .select() + .from(core_ai_role_policies) + .where(inArray(core_ai_role_policies.roleId, roleIds)), + this.db + .select() + .from(core_ai_user_overrides) + .where(eq(core_ai_user_overrides.userId, userId)) + .limit(1), + this.loadSettings(), + ]); + + return resolvePolicy({ + defaultGranted, + defaultMonthlyPoints: settings.defaultMonthlyPoints, + override: override + ? { + blocked: override.blocked, + monthlyPoints: parseDecimalOrNull(override.monthlyPoints), + unlimited: override.unlimited, + } + : null, + permissions: permissions.map(row => ({ + dailyLimit: row.dailyLimit, + granted: row.granted, + })), + policies: policies.map(row => ({ + monthlyPoints: parseDecimalOrNull(row.monthlyPoints), + unlimited: row.unlimited, + })), + root: roles.some(role => role.root), + }); + } + + /** + * Idempotent by construction: the run's `settlement` flips from `pending` + * exactly once, under the row lock, and only that transaction moves money. + */ + async settle( + runId: number, + settlement: AiSettlement, + ): Promise { + return await this.db.transaction(async tx => { + const [run] = await tx + .select() + .from(core_ai_runs) + .where(eq(core_ai_runs.id, runId)) + .for("update"); + if (run?.settlement !== "pending") { + return { + applied: false, + chargedPoints: parseDecimalOrNull(run?.chargedPoints ?? null) ?? 0n, + chargedUsd: parseDecimalOrNull(run?.chargedUsd ?? null) ?? 0n, + costKnown: run?.costUsd !== null && run?.costUsd !== undefined, + }; + } + + const calls = await tx + .select() + .from(core_ai_calls) + .where(eq(core_ai_calls.runId, runId)); + const totals = summarizeCalls(calls); + const reservedUsd = parseDecimal(run.reservedUsd); + // Unknown is never free: an unknown cost is charged the whole hold. + const chargedUsd = + totals.costUsd ?? maxDecimal(reservedUsd, totals.knownUsd); + + const holds = await tx + .select({ + amount: core_ai_reservations.amount, + budgetPeriodId: core_ai_reservations.budgetPeriodId, + id: core_ai_reservations.id, + scopeKey: core_ai_budget_periods.scopeKey, + unit: core_ai_budget_periods.unit, + }) + .from(core_ai_reservations) + .innerJoin( + core_ai_budget_periods, + eq(core_ai_budget_periods.id, core_ai_reservations.budgetPeriodId), + ) + .where( + and( + eq(core_ai_reservations.runId, runId), + eq(core_ai_reservations.status, "active"), + ), + ) + .orderBy(asc(core_ai_budget_periods.scopeKey)); + + if (holds.length > 0) { + await tx + .select({ id: core_ai_budget_periods.id }) + .from(core_ai_budget_periods) + .where( + inArray( + core_ai_budget_periods.id, + holds.map(hold => hold.budgetPeriodId), + ), + ) + .orderBy(asc(core_ai_budget_periods.scopeKey)) + .for("update"); + } + + let chargedPoints = 0n; + for (const hold of holds) { + const charge = settlementCharge({ + chargedUsd, + delivered: settlement.delivered, + unit: hold.unit, + }); + if (hold.unit === "points") chargedPoints = charge; + await tx + .update(core_ai_budget_periods) + .set({ + reservedAmount: sql`${core_ai_budget_periods.reservedAmount} - ${hold.amount}`, + spentAmount: sql`${core_ai_budget_periods.spentAmount} + ${formatDecimal(charge)}`, + unknownCount: + totals.costUsd === null && hold.unit === "usd" + ? sql`${core_ai_budget_periods.unknownCount} + 1` + : undefined, + }) + .where(eq(core_ai_budget_periods.id, hold.budgetPeriodId)); + } + if (holds.length > 0) { + await tx + .update(core_ai_reservations) + .set({ settledAt: settlement.finishedAt, status: "settled" }) + .where( + inArray( + core_ai_reservations.id, + holds.map(hold => hold.id), + ), + ); + } + + await tx + .update(core_ai_runs) + .set({ + ...totals.usage, + chargedPoints: formatDecimal(chargedPoints), + chargedUsd: formatDecimal(chargedUsd), + costSource: totals.source, + costUsd: formatDecimalOrNull(totals.costUsd), + errorCode: settlement.errorCode, + finishedAt: settlement.finishedAt, + leaseExpiresAt: null, + providerModelId: totals.providerModelId ?? run.providerModelId, + settledAt: settlement.finishedAt, + settlement: "settled", + status: settlement.status, + }) + .where(eq(core_ai_runs.id, runId)); + + return { + applied: true, + chargedPoints, + chargedUsd, + costKnown: totals.costUsd !== null, + }; + }); + } +} + +interface PolicyInputs { + defaultGranted: boolean; + defaultMonthlyPoints: Decimal; + override: null | { + blocked: boolean; + monthlyPoints: Decimal | null; + unlimited: boolean; + }; + permissions: { dailyLimit: null | number; granted: boolean }[]; + policies: { monthlyPoints: Decimal | null; unlimited: boolean }[]; + root: boolean; +} + +/** + * Multiple roles, existing semantics: any role that grants, grants. Allowances + * are individual: the largest one applies - they are never summed into a pool. + * An explicit user override replaces whatever the roles give. + */ +export const resolvePolicy = ({ + defaultGranted, + defaultMonthlyPoints, + override, + permissions, + policies, + root, +}: PolicyInputs): AiUserPolicy => { + if (override?.blocked) { + return { dailyLimit: null, granted: false, monthlyPoints: 0n }; + } + + const granting = permissions.filter(row => row.granted); + const granted = + root || (permissions.length === 0 ? defaultGranted : granting.length > 0); + const dailyLimit = + root || + granting.length === 0 || + granting.some(row => row.dailyLimit === null) + ? null + : Math.max(...granting.map(row => row.dailyLimit ?? 0)); + + let monthlyPoints: Decimal | null; + if (override?.unlimited) { + monthlyPoints = null; + } else if ( + override?.monthlyPoints !== null && + override?.monthlyPoints !== undefined + ) { + monthlyPoints = override.monthlyPoints; + } else if (root || policies.some(policy => policy.unlimited)) { + monthlyPoints = null; + } else { + monthlyPoints = policies + .map(policy => policy.monthlyPoints ?? defaultMonthlyPoints) + .reduce(maxDecimal, defaultMonthlyPoints); + } + + return { dailyLimit, granted, monthlyPoints }; +}; + +type CallRow = typeof core_ai_calls.$inferSelect; + +/** Sums a run's calls once. A run is "known" only when every call is. */ +export const summarizeCalls = (calls: CallRow[]) => { + const sumOf = (pick: (call: CallRow) => null | number) => { + const values = calls.map(pick); + + return values.every(value => value === null) + ? null + : values.reduce((total, value) => total + (value ?? 0), 0); + }; + const costs = calls.map(call => parseDecimalOrNull(call.costUsd)); + const allKnown = calls.length > 0 && costs.every(cost => cost !== null); + const sources = new Set(calls.map(call => call.costSource)); + let source: "manual" | "mixed" | "pricing" | "provider" | "unknown"; + if (!allKnown) source = "unknown"; + else if (sources.size === 1) source = [...sources][0]; + else source = "mixed"; + + const last = calls.at(-1); + + return { + costUsd: calls.length === 0 ? 0n : allKnown ? sumDecimals(costs) : null, + knownUsd: sumDecimals( + costs.filter((cost): cost is Decimal => cost !== null), + ), + providerModelId: last?.providerModelId ?? null, + source: calls.length === 0 ? ("pricing" as const) : source, + usage: { + cacheReadTokens: sumOf(call => call.cacheReadTokens), + cacheWriteTokens: sumOf(call => call.cacheWriteTokens), + inputTokens: sumOf(call => call.inputTokens), + outputTokens: sumOf(call => call.outputTokens), + reasoningTokens: sumOf(call => call.reasoningTokens), + }, + }; +}; diff --git a/packages/vitnode/src/api/lib/ai/pricing.ts b/packages/vitnode/src/api/lib/ai/pricing.ts new file mode 100644 index 000000000..70d592a79 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/pricing.ts @@ -0,0 +1,184 @@ +import { z } from "zod"; + +import type { Decimal } from "./decimal"; +import type { AiUsage } from "./usage-cost"; + +import { + decimalFromInteger, + divideByInteger, + formatDecimal, + maxDecimal, + multiplyByInteger, + parseDecimal, + sumDecimals, +} from "./decimal"; + +const zodPrice = z + .string() + .regex( + /^\d+(\.\d+)?$/, + 'A price is a non-negative decimal string, e.g. "3.00".', + ); + +/** USD per one million tokens (and per request/image for flat units). */ +export const zodAiPricingRates = z.object({ + inputPerMillion: zodPrice, + outputPerMillion: zodPrice, + cacheReadPerMillion: zodPrice.optional(), + cacheWritePerMillion: zodPrice.optional(), + /** A flat amount billed for every call, e.g. a gateway fee. */ + perRequest: zodPrice.optional(), + /** A flat amount per input image, for providers that bill images separately. */ + perImage: zodPrice.optional(), +}); + +export type AiPricingRates = z.infer; + +/** + * How one model is priced on one connection. A gateway and the direct + * provider are different connections with different prices - never assume + * one from the other. + */ +export const zodAiPricing = z.object({ + rates: zodAiPricingRates, + /** + * Long-context pricing: when a call's input tokens exceed `aboveInputTokens`, + * the whole call is billed at that tier's rates. Sorted ascending. + */ + tiers: z + .array( + z.object({ + aboveInputTokens: z.number().int().positive(), + rates: zodAiPricingRates, + }), + ) + .optional(), +}); + +export type AiPricing = z.infer; + +export type AiPriceEstimate = + | { amount: Decimal; status: "complete" } + | { reason: string; status: "incomplete" }; + +const PER_MILLION = 1_000_000n; + +const priceTokens = (perMillion: string, tokens: number): Decimal => + divideByInteger( + multiplyByInteger(parseDecimal(perMillion), tokens), + PER_MILLION, + ); + +const ratesFor = (pricing: AiPricing, inputTokens: number): AiPricingRates => { + const tiers = [...(pricing.tiers ?? [])].sort( + (a, b) => b.aboveInputTokens - a.aboveInputTokens, + ); + + return ( + tiers.find(tier => inputTokens > tier.aboveInputTokens)?.rates ?? + pricing.rates + ); +}; + +/** + * Prices one call from its reported usage. Anything the pricing cannot price + * exactly - unknown token counts, cache tokens without a cache rate - makes + * the result `incomplete` rather than silently cheaper. + * + * Reasoning tokens are never added on top: the AI SDK reports them as part of + * `outputTokens`, and providers bill them as output. + */ +export const priceUsage = ( + pricing: AiPricing, + usage: AiUsage, + { images = 0 }: { images?: number } = {}, +): AiPriceEstimate => { + if (usage.inputTokens === null || usage.outputTokens === null) { + return { status: "incomplete", reason: "token usage was not reported" }; + } + + const cacheRead = usage.cacheReadTokens ?? 0; + const cacheWrite = usage.cacheWriteTokens ?? 0; + const uncached = usage.inputTokens - cacheRead - cacheWrite; + if (uncached < 0) { + return { status: "incomplete", reason: "cache tokens exceed input tokens" }; + } + + const rates = ratesFor(pricing, usage.inputTokens); + if (cacheRead > 0 && rates.cacheReadPerMillion === undefined) { + return { status: "incomplete", reason: "no cache read price" }; + } + if (cacheWrite > 0 && rates.cacheWritePerMillion === undefined) { + return { status: "incomplete", reason: "no cache write price" }; + } + + const parts: Decimal[] = [ + priceTokens(rates.inputPerMillion, uncached), + priceTokens(rates.outputPerMillion, usage.outputTokens), + ]; + if (cacheRead > 0 && rates.cacheReadPerMillion) { + parts.push(priceTokens(rates.cacheReadPerMillion, cacheRead)); + } + if (cacheWrite > 0 && rates.cacheWritePerMillion) { + parts.push(priceTokens(rates.cacheWritePerMillion, cacheWrite)); + } + if (rates.perRequest) parts.push(parseDecimal(rates.perRequest)); + if (images > 0 && rates.perImage) { + parts.push(multiplyByInteger(parseDecimal(rates.perImage), images)); + } + + return { status: "complete", amount: sumDecimals(parts) }; +}; + +/** + * The most one call can cost: every input token billed at the dearest input + * rate of the dearest tier it could fall into, and the full output budget. + */ +export const maxCallCost = ( + pricing: AiPricing, + { + images, + inputTokens, + outputTokens, + }: { images: number; inputTokens: number; outputTokens: number }, +): Decimal => { + const candidates = [ + pricing.rates, + ...(pricing.tiers ?? []) + .filter(tier => inputTokens > tier.aboveInputTokens) + .map(tier => tier.rates), + ]; + + return candidates + .map(rates => { + const inputRate = [rates.inputPerMillion, rates.cacheWritePerMillion] + .filter((rate): rate is string => rate !== undefined) + .map(parseDecimal) + .reduce(maxDecimal); + const parts = [ + divideByInteger(multiplyByInteger(inputRate, inputTokens), PER_MILLION), + priceTokens(rates.outputPerMillion, outputTokens), + rates.perRequest ? parseDecimal(rates.perRequest) : 0n, + rates.perImage && images > 0 + ? multiplyByInteger(parseDecimal(rates.perImage), images) + : 0n, + ]; + + return sumDecimals(parts); + }) + .reduce(maxDecimal, decimalFromInteger(0)); +}; + +/** A stable label for config-declared pricing, so a snapshot names its source. */ +export const pricingFingerprint = (pricing: AiPricing): string => { + const text = JSON.stringify(pricing); + let hash = 0x811c9dc5; + for (let index = 0; index < text.length; index++) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + + return hash.toString(16).padStart(8, "0"); +}; + +export const formatUsd = (amount: Decimal): string => formatDecimal(amount); diff --git a/packages/vitnode/src/api/lib/ai/registry.test.ts b/packages/vitnode/src/api/lib/ai/registry.test.ts new file mode 100644 index 000000000..3d6bc9bed --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/registry.test.ts @@ -0,0 +1,178 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { buildApiPlugin } from "../plugin"; +import { AiActionDefinitionError, defineAiAction } from "./action"; +import { collectAiActions, UnknownAiActionError } from "./registry"; + +const summarize = (id = "summary.generate") => + defineAiAction({ + authorize: () => true, + buildPrompt: input => ({ prompt: input.text }), + defaults: { + maxInputCharacters: 1_000, + maxOutputTokens: 100, + timeoutMs: 10_000, + }, + id, + inputSchema: z.object({ text: z.string() }), + output: "text", + outputSchema: z.string().min(1), + parseText: text => text.trim(), + permission: "summary", + promptVersion: 1, + requiredCapabilities: ["text"], + }); + +describe("defineAiAction", () => { + it("fills safe defaults and normalises the permission", () => { + const action = summarize(); + + expect(action.defaults).toMatchObject({ + dailyLimit: null, + maxImages: 0, + maxRetries: 0, + maxSteps: 1, + }); + expect(action.permission).toEqual({ + defaultGranted: false, + key: "summary", + }); + expect(action.actors).toEqual(["user"]); + }); + + it.each([ + ["an invalid id", { id: "Bad Id" }], + ["a zero prompt version", { promptVersion: 0 }], + ["no capabilities", { requiredCapabilities: [] }], + ["an unsupported capability", { requiredCapabilities: ["telepathy"] }], + [ + "an out-of-range timeout", + { + defaults: { maxInputCharacters: 10, maxOutputTokens: 10, timeoutMs: 5 }, + }, + ], + [ + "too many retries", + { + defaults: { + maxInputCharacters: 10, + maxOutputTokens: 10, + maxRetries: 9, + timeoutMs: 5_000, + }, + }, + ], + ["a user action without authorize()", { authorize: undefined }], + ])("refuses %s", (_label, override) => { + expect(() => + defineAiAction({ + ...summarize(), + permission: "summary", + ...override, + } as never), + ).toThrow(AiActionDefinitionError); + }); + + it("requires structured-output for object actions and image-input bounds for vision", () => { + expect(() => + defineAiAction({ + actors: ["system"], + buildPrompt: () => ({ prompt: "x" }), + defaults: { + maxInputCharacters: 10, + maxOutputTokens: 10, + timeoutMs: 5_000, + }, + id: "facts.extract", + inputSchema: z.object({}), + output: "object", + outputSchema: z.object({ facts: z.array(z.string()) }), + permission: "facts", + promptVersion: 1, + requiredCapabilities: ["text"], + }), + ).toThrow(/structured-output/); + + const vision = defineAiAction({ + actors: ["system"], + buildPrompt: () => ({ prompt: "x" }), + defaults: { + maxInputCharacters: 10, + maxOutputTokens: 10, + timeoutMs: 5_000, + }, + id: "media.describe", + inputSchema: z.object({}), + output: "text", + outputSchema: z.string(), + parseText: text => text, + permission: "media", + promptVersion: 1, + requiredCapabilities: ["text", "image-input"], + }); + + expect(vision.defaults.maxImages).toBe(1); + }); +}); + +describe("AI action registry", () => { + it("names actions by plugin and local id", () => { + const registry = collectAiActions([ + { aiActions: [summarize()], pluginId: "@acme/blog" }, + ]); + + expect(registry.get("@acme/blog:summary.generate").permissionKey).toBe( + "@acme/blog:summary", + ); + }); + + it("allows the same local id in two plugins", () => { + const registry = collectAiActions([ + { aiActions: [summarize()], pluginId: "@acme/blog" }, + { aiActions: [summarize()], pluginId: "@acme/forum" }, + ]); + + expect(registry.all()).toHaveLength(2); + }); + + it("refuses a duplicate canonical identity", () => { + expect(() => + collectAiActions([ + { aiActions: [summarize(), summarize()], pluginId: "@acme/blog" }, + ]), + ).toThrow(/registered twice/); + }); + + it("refuses the duplicate already when the plugin is built", () => { + expect(() => + buildApiPlugin({ + aiActions: [summarize(), summarize()], + pluginId: "@acme/blog", + }), + ).toThrow(AiActionDefinitionError); + }); + + it("throws a named error for an unknown action", () => { + const registry = collectAiActions([]); + + expect(() => registry.get("@acme/blog:nope")).toThrow(UnknownAiActionError); + expect(registry.find("@acme/blog:nope")).toBeUndefined(); + }); + + it("projects only serializable metadata", () => { + const registry = collectAiActions([ + { aiActions: [summarize()], pluginId: "@acme/blog" }, + ]); + const [metadata] = registry.publicMetadata(); + + expect(JSON.parse(JSON.stringify(metadata))).toEqual(metadata); + expect(metadata).not.toHaveProperty("buildPrompt"); + expect(metadata).not.toHaveProperty("inputSchema"); + expect(metadata).toMatchObject({ + key: "@acme/blog:summary.generate", + permission: { key: "@acme/blog:summary" }, + }); + }); +}); diff --git a/packages/vitnode/src/api/lib/ai/registry.ts b/packages/vitnode/src/api/lib/ai/registry.ts new file mode 100644 index 000000000..a9bb3ded7 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/registry.ts @@ -0,0 +1,178 @@ +import type { + AiActionDefaults, + AiActionOutputMode, + AiActorType, + AnyAiActionDefinition, +} from "./action"; +import type { z } from "zod"; + +import type { AiModelCapability } from "./capabilities"; + +import { AiActionDefinitionError } from "./action"; + +/** `:`, e.g. `@vitnode/blog:excerpt.generate`. */ +export type AiActionKey = `${string}:${string}`; + +export interface RegisteredAiAction { + definition: AnyAiActionDefinition; + /** Canonical identity. */ + key: AiActionKey; + /** Canonical permission identity, `:`. */ + permissionKey: string; + pluginId: string; +} + +/** + * The serializable half of an action - what the AdminCP and the editor may + * see. Prompt builders, schemas and models never leave the server. + */ +export interface AiActionPublicMetadata { + actors: AiActorType[]; + defaults: AiActionDefaults; + description: null | string; + key: AiActionKey; + localId: string; + output: AiActionOutputMode; + permission: { defaultGranted: boolean; key: string }; + pluginId: string; + promptVersion: number; + requiredCapabilities: AiModelCapability[]; +} + +export const aiActionKey = (pluginId: string, localId: string): AiActionKey => + `${pluginId}:${localId}`; + +/** + * A canonical action key that remembers its action's input and output types, + * so `c.get("ai").run({ action, input })` infers both. + */ +export type TypedAiActionKey = AiActionKey & { + readonly __aiAction?: { input: Input; output: Output }; +}; + +export const aiActionRef = ( + pluginId: string, + definition: Definition, +): TypedAiActionKey< + z.input, + z.output +> => aiActionKey(pluginId, definition.id); + +/** Splits on the *last* colon: plugin ids are npm names and may contain `:` only in theory. */ +export const parseAiActionKey = ( + key: string, +): null | { localId: string; pluginId: string } => { + const index = key.lastIndexOf(":"); + if (index <= 0 || index === key.length - 1) return null; + + return { pluginId: key.slice(0, index), localId: key.slice(index + 1) }; +}; + +export class UnknownAiActionError extends Error { + constructor(key: string) { + super(`AI action "${key}" is not registered by any installed plugin.`); + this.name = "UnknownAiActionError"; + } +} + +export class AiActionRegistry { + constructor(actions: RegisteredAiAction[]) { + this.actions = new Map(actions.map(action => [action.key, action])); + } + + private readonly actions: Map; + + all(): RegisteredAiAction[] { + return [...this.actions.values()]; + } + + find(key: string): RegisteredAiAction | undefined { + return this.actions.get(key); + } + + get(key: string): RegisteredAiAction { + const action = this.actions.get(key); + if (!action) throw new UnknownAiActionError(key); + + return action; + } + + has(key: string): boolean { + return this.actions.has(key); + } + + publicMetadata(): AiActionPublicMetadata[] { + return this.all().map(projectAiAction); + } +} + +export const projectAiAction = ({ + definition, + key, + permissionKey, + pluginId, +}: RegisteredAiAction): AiActionPublicMetadata => ({ + actors: [...definition.actors], + defaults: { ...definition.defaults }, + description: definition.description ?? null, + key, + localId: definition.id, + output: definition.output, + permission: { + defaultGranted: definition.permission.defaultGranted, + key: permissionKey, + }, + pluginId, + promptVersion: definition.promptVersion, + requiredCapabilities: [...definition.requiredCapabilities], +}); + +/** + * Registers every plugin's actions under their canonical identity. The same + * local id may appear in two plugins; the same canonical identity may not. + */ +export const collectAiActions = ( + plugins: { aiActions?: AnyAiActionDefinition[]; pluginId: string }[], +): AiActionRegistry => { + const registered: RegisteredAiAction[] = []; + const seen = new Set(); + // A permission key names one descriptor: two actions sharing a key must + // agree on its default, or which one wins would depend on plugin order. + const permissionDefaults = new Map(); + + for (const plugin of plugins) { + for (const definition of plugin.aiActions ?? []) { + const key = aiActionKey(plugin.pluginId, definition.id); + if (seen.has(key)) { + throw new AiActionDefinitionError( + `AI action "${key}" is registered twice. Each plugin's action ids must be unique.`, + ); + } + seen.add(key); + + const permissionKey = `${plugin.pluginId}:${definition.permission.key}`; + const known = permissionDefaults.get(permissionKey); + if ( + known !== undefined && + known !== definition.permission.defaultGranted + ) { + throw new AiActionDefinitionError( + `AI permission "${permissionKey}" is declared with different defaultGranted values.`, + ); + } + permissionDefaults.set( + permissionKey, + definition.permission.defaultGranted, + ); + + registered.push({ + definition, + key, + permissionKey, + pluginId: plugin.pluginId, + }); + } + } + + return new AiActionRegistry(registered); +}; diff --git a/packages/vitnode/src/api/lib/ai/runner.test.ts b/packages/vitnode/src/api/lib/ai/runner.test.ts new file mode 100644 index 000000000..099102f2e --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/runner.test.ts @@ -0,0 +1,660 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { APICallError } from "ai"; +import { convertArrayToReadableStream, MockLanguageModelV4 } from "ai/test"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import type { AIModelDefinition } from "../../models/ai"; +import type { AiPricing } from "./pricing"; + +import { defineAiAction } from "./action"; +import { parseDecimal } from "./decimal"; +import { AiError } from "./errors"; +import { MemoryAiLedger } from "./memory-ledger"; +import { collectAiActions } from "./registry"; +import { AiRunner } from "./runner"; + +type LanguageModelV4GenerateResult = Awaited< + ReturnType +>; + +const PRICING: AiPricing = { + rates: { inputPerMillion: "1000", outputPerMillion: "2000" }, +}; + +const generated = ( + text: string, + usage = { input: 10, output: 5 }, +): LanguageModelV4GenerateResult => ({ + content: [{ text, type: "text" }], + finishReason: { raw: "stop", unified: "stop" }, + usage: { + inputTokens: { + cacheRead: 0, + cacheWrite: 0, + noCache: usage.input, + total: usage.input, + }, + outputTokens: { reasoning: 0, text: usage.output, total: usage.output }, + }, + warnings: [], +}); + +const answering = (...texts: string[]) => + new MockLanguageModelV4({ doGenerate: texts.map(text => generated(text)) }); + +const failingThen = (texts: string[], failures: number) => { + let calls = 0; + + return new MockLanguageModelV4({ + doGenerate: async () => { + calls += 1; + if (calls <= failures) { + throw new APICallError({ + isRetryable: true, + message: "overloaded", + requestBodyValues: {}, + statusCode: 529, + url: "https://provider.test", + }); + } + + return Promise.resolve(generated(texts[calls - failures - 1] ?? "ok")); + }, + }); +}; + +const summarize = defineAiAction({ + authorize: ({ input }) => input.text !== "forbidden", + buildPrompt: input => ({ prompt: input.text, system: "Summarize." }), + defaults: { + maxInputCharacters: 1_000, + maxOutputTokens: 100, + timeoutMs: 10_000, + }, + id: "summary.generate", + inputSchema: z.object({ text: z.string().min(1) }), + output: "text", + outputSchema: z.string().min(1), + parseText: text => text.trim(), + permission: "summary", + promptVersion: 1, + requiredCapabilities: ["text"], +}); + +const describeImage = defineAiAction({ + actors: ["system"], + buildPrompt: input => ({ + messages: [ + { + content: [ + { text: "Describe the image.", type: "text" }, + { data: input.image, mediaType: "image/png", type: "file" }, + ], + role: "user", + }, + ], + }), + defaults: { maxInputCharacters: 10, maxOutputTokens: 100, timeoutMs: 10_000 }, + id: "media.alt.generate", + inputSchema: z.object({ image: z.instanceof(Uint8Array) }), + measureInput: () => 0, + output: "text", + outputSchema: z.string().min(1), + parseText: text => text.trim(), + permission: "media.alt", + promptVersion: 1, + requiredCapabilities: ["text", "image-input"], +}); + +const registry = collectAiActions([ + { aiActions: [summarize], pluginId: "@acme/notes" }, + { aiActions: [describeImage], pluginId: "@vitnode/core" }, +]); + +const SUMMARY = "@acme/notes:summary.generate"; +const ALT = "@vitnode/core:media.alt.generate"; + +const context = (userId: null | number = 7) => + ({ + get: (key: string) => { + if (key === "user") return userId === null ? null : { id: userId }; + if (key === "admin") return null; + + return undefined; + }, + }) as unknown as Context; + +const setup = ({ + ledger = new MemoryAiLedger(), + models, + userId, +}: { + ledger?: MemoryAiLedger; + models: AIModelDefinition[]; + userId?: null | number; +}) => ({ + ledger, + runner: new AiRunner({ + adapters: [], + c: context(userId), + ledger, + models, + registry, + }), +}); + +const textModel = ( + model: MockLanguageModelV4, + overrides: Partial = {}, +): AIModelDefinition => ({ + id: "default", + model, + name: "Test", + pricing: PRICING, + ...overrides, +}); + +const codeOf = async (promise: Promise) => { + try { + await promise; + } catch (error) { + if (error instanceof AiError) return error.code; + throw error; + } + + return "resolved"; +}; + +describe("AiRunner.run", () => { + it("delivers a validated result and charges points for its cost", async () => { + const { ledger, runner } = setup({ + models: [textModel(answering(" A short summary. "))], + }); + + const result = await runner.run({ + action: SUMMARY, + input: { text: "Long" }, + }); + + expect(result.output).toBe("A short summary."); + // 10 input * $1000/1M + 5 output * $2000/1M = $0.02 = 20 points. + expect(result.usage).toEqual({ + chargedPoints: "20", + costKnown: true, + modelId: "default", + }); + expect(ledger.spentPoints(7)).toBe(parseDecimal("20")); + expect(ledger.calls).toHaveLength(1); + expect(ledger.calls[0].finish?.usage).toMatchObject({ + inputTokens: 10, + outputTokens: 5, + }); + }); + + it("charges the user nothing when the provider fails, and records each call", async () => { + const model = failingThen([], 5); + const { ledger, runner } = setup({ models: [textModel(model)] }); + + expect( + await codeOf(runner.run({ action: SUMMARY, input: { text: "x" } })), + ).toBe("AI_PROVIDER_FAILED"); + expect(ledger.spentPoints(7)).toBe(0n); + expect(ledger.runs[0]).toMatchObject({ settled: true, status: "failed" }); + }); + + it("retries, then sums every billable call once", async () => { + const model = failingThen(["Done."], 1); + const ledger = new MemoryAiLedger({ + actionSettings: { [SUMMARY]: { maxRetries: 1 } }, + }); + const { runner } = setup({ ledger, models: [textModel(model)] }); + + const result = await runner.run({ action: SUMMARY, input: { text: "x" } }); + + expect(result.output).toBe("Done."); + expect(ledger.calls).toHaveLength(2); + expect(ledger.calls[0].finish?.status).toBe("failed"); + expect(result.usage.chargedPoints).toBe("20"); + }); + + it("falls back to a compatible model after the primary fails", async () => { + const ledger = new MemoryAiLedger({ + actionSettings: { + [SUMMARY]: { fallbackModelId: "backup", modelId: "default" }, + }, + }); + const { runner } = setup({ + ledger, + models: [ + textModel(failingThen([], 9)), + textModel(answering("From backup."), { id: "backup" }), + ], + }); + + const result = await runner.run({ action: SUMMARY, input: { text: "x" } }); + + expect(result).toMatchObject({ + output: "From backup.", + usage: { modelId: "backup" }, + }); + }); + + it("refuses empty output without charging the user, while the site pays", async () => { + const { ledger, runner } = setup({ models: [textModel(answering(" "))] }); + + expect( + await codeOf(runner.run({ action: SUMMARY, input: { text: "x" } })), + ).toBe("AI_INVALID_OUTPUT"); + expect(ledger.spentPoints(7)).toBe(0n); + expect(ledger.spentUsd()).toBe(parseDecimal("0.02")); + }); + + it("records an unpriced cost as unknown, never as zero", async () => { + const ledger = new MemoryAiLedger({ + policy: () => ({ dailyLimit: null, granted: true, monthlyPoints: null }), + }); + const { runner } = setup({ + ledger, + models: [textModel(answering("Hi."), { pricing: undefined })], + }); + + const result = await runner.run({ action: SUMMARY, input: { text: "x" } }); + + expect(result.usage.costKnown).toBe(false); + expect(ledger.calls[0].finish?.cost).toEqual({ + amountUsd: null, + reason: "no pricing", + source: "unknown", + }); + const global = [...ledger.budgets.values()].find( + row => row.scopeKey === "global", + ); + expect(global?.unknownCount).toBe(1); + }); + + it("refuses an unpriced model before any call when a cap applies", async () => { + const model = answering("Hi."); + const ledger = new MemoryAiLedger({ + policy: () => ({ + dailyLimit: null, + granted: true, + monthlyPoints: parseDecimal("100"), + }), + }); + const { runner } = setup({ + ledger, + models: [textModel(model, { pricing: undefined })], + }); + + expect( + await codeOf(runner.run({ action: SUMMARY, input: { text: "x" } })), + ).toBe("AI_PRICING_MISSING"); + expect(model.doGenerateCalls).toHaveLength(0); + }); + + it("stops at the user's monthly points with a reset date", async () => { + const model = answering("Hi."); + const ledger = new MemoryAiLedger({ + policy: () => ({ + dailyLimit: null, + granted: true, + monthlyPoints: parseDecimal("1"), + }), + }); + const { runner } = setup({ ledger, models: [textModel(model)] }); + + const error = await runner + .run({ action: SUMMARY, input: { text: "x" } }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(AiError); + expect((error as AiError).body).toMatchObject({ + code: "AI_USER_LIMIT_REACHED", + resetsAt: expect.any(String), + }); + expect(model.doGenerateCalls).toHaveLength(0); + }); + + it("stops at the global budget", async () => { + const ledger = new MemoryAiLedger({ + settings: { monthlyBudgetUsd: parseDecimal("0.0001") }, + }); + const { runner } = setup({ ledger, models: [textModel(answering("Hi."))] }); + + expect( + await codeOf(runner.run({ action: SUMMARY, input: { text: "x" } })), + ).toBe("AI_BUDGET_EXHAUSTED"); + }); + + it("enforces the daily invocation limit", async () => { + const ledger = new MemoryAiLedger({ + policy: () => ({ dailyLimit: 1, granted: true, monthlyPoints: null }), + }); + const { runner } = setup({ + ledger, + models: [textModel(answering("One.", "Two."))], + }); + + await runner.run({ action: SUMMARY, input: { text: "x" } }); + + expect( + await codeOf(runner.run({ action: SUMMARY, input: { text: "y" } })), + ).toBe("AI_DAILY_LIMIT_REACHED"); + }); + + it("refuses actors without the AI permission or without access to the content", async () => { + const denied = setup({ + ledger: new MemoryAiLedger({ + policy: () => ({ + dailyLimit: null, + granted: false, + monthlyPoints: null, + }), + }), + models: [textModel(answering("Hi."))], + }); + const allowed = setup({ models: [textModel(answering("Hi."))] }); + const anonymous = setup({ + models: [textModel(answering("Hi."))], + userId: null, + }); + + expect( + await codeOf( + denied.runner.run({ action: SUMMARY, input: { text: "x" } }), + ), + ).toBe("AI_UNAUTHORIZED"); + expect( + await codeOf( + allowed.runner.run({ action: SUMMARY, input: { text: "forbidden" } }), + ), + ).toBe("AI_UNAUTHORIZED"); + expect( + await codeOf( + anonymous.runner.run({ action: SUMMARY, input: { text: "x" } }), + ), + ).toBe("AI_UNAUTHORIZED"); + }); + + it("never lets a client run a system-only action", async () => { + const { runner } = setup({ models: [textModel(answering("Hi."))] }); + + expect( + await codeOf( + runner.run({ action: ALT, input: { image: new Uint8Array([1]) } }), + ), + ).toBe("AI_UNAUTHORIZED"); + }); + + it("honours the global switch and a disabled action", async () => { + const off = setup({ + ledger: new MemoryAiLedger({ settings: { enabled: false } }), + models: [textModel(answering("Hi."))], + }); + const disabled = setup({ + ledger: new MemoryAiLedger({ + actionSettings: { [SUMMARY]: { enabled: false } }, + }), + models: [textModel(answering("Hi."))], + }); + + expect( + await codeOf(off.runner.run({ action: SUMMARY, input: { text: "x" } })), + ).toBe("AI_DISABLED"); + expect( + await codeOf( + disabled.runner.run({ action: SUMMARY, input: { text: "x" } }), + ), + ).toBe("AI_ACTION_DISABLED"); + }); + + it("validates input and bounds its size", async () => { + const { runner } = setup({ models: [textModel(answering("Hi."))] }); + + expect( + await codeOf(runner.run({ action: SUMMARY, input: { text: "" } })), + ).toBe("AI_INVALID_INPUT"); + expect( + await codeOf( + runner.run({ action: SUMMARY, input: { text: "x".repeat(1_001) } }), + ), + ).toBe("AI_INPUT_TOO_LARGE"); + expect( + await codeOf(runner.run({ action: "@acme/notes:nope", input: {} })), + ).toBe("AI_ACTION_UNKNOWN"); + }); + + it("runs a duplicate submission only once", async () => { + const model = answering("Once.", "Twice."); + const { ledger, runner } = setup({ models: [textModel(model)] }); + + await runner.run({ + action: SUMMARY, + idempotencyKey: "abc", + input: { text: "x" }, + }); + + expect( + await codeOf( + runner.run({ + action: SUMMARY, + idempotencyKey: "abc", + input: { text: "x" }, + }), + ), + ).toBe("AI_DUPLICATE_REQUEST"); + expect(model.doGenerateCalls).toHaveLength(1); + expect(ledger.spentPoints(7)).toBe(parseDecimal("20")); + }); + + it("settles a run only once", async () => { + const { ledger, runner } = setup({ models: [textModel(answering("Hi."))] }); + const { runId } = await runner.run({ + action: SUMMARY, + input: { text: "x" }, + }); + + const again = await ledger.settle(runId, { + delivered: true, + errorCode: null, + finishedAt: new Date(), + status: "succeeded", + }); + + expect(again.applied).toBe(false); + expect(ledger.spentPoints(7)).toBe(parseDecimal("20")); + }); +}); + +describe("model capabilities", () => { + it("never assumes vision for a model that does not declare it", async () => { + const { runner } = setup({ + models: [textModel(answering("A cat."))], + userId: null, + }); + + expect( + await codeOf( + runner.runAsSystem({ + action: ALT, + input: { image: new Uint8Array([1]) }, + }), + ), + ).toBe("AI_MODEL_INCOMPATIBLE"); + }); + + it("picks the first model that declares every required capability", async () => { + const vision = answering("A cat on a sofa."); + const { runner } = setup({ + models: [ + textModel(answering("unused")), + textModel(vision, { + capabilities: ["text", "image-input"], + id: "vision", + }), + ], + userId: null, + }); + + const result = await runner.runAsSystem({ + action: ALT, + input: { image: new Uint8Array([1]) }, + }); + + expect(result).toMatchObject({ + output: "A cat on a sofa.", + usage: { chargedPoints: "0", modelId: "vision" }, + }); + expect(vision.doGenerateCalls).toHaveLength(1); + }); + + it("refuses an admin-assigned model that lacks a capability", async () => { + const ledger = new MemoryAiLedger({ + actionSettings: { [ALT]: { modelId: "default" } }, + }); + const { runner } = setup({ + ledger, + models: [ + textModel(answering("x")), + textModel(answering("y"), { + capabilities: ["text", "image-input"], + id: "vision", + }), + ], + userId: null, + }); + + expect( + await codeOf( + runner.runAsSystem({ + action: ALT, + input: { image: new Uint8Array([1]) }, + }), + ), + ).toBe("AI_MODEL_INCOMPATIBLE"); + }); +}); + +describe("system work", () => { + const visionModels = [ + textModel(answering("A cat.", "A dog."), { + capabilities: ["text", "image-input"], + }), + ]; + + it("never touches personal points, but pays from the global budget", async () => { + const ledger = new MemoryAiLedger({ + policy: () => ({ dailyLimit: 0, granted: false, monthlyPoints: 0n }), + }); + const { runner } = setup({ ledger, models: visionModels, userId: null }); + + await runner.runAsSystem({ + action: ALT, + input: { image: new Uint8Array([1]) }, + }); + + expect(ledger.spentUsd("global")).toBeGreaterThan(0n); + expect(ledger.spentUsd("system")).toBe(ledger.spentUsd("global")); + expect( + [...ledger.budgets.values()].some(row => + row.scopeKey.startsWith("user:"), + ), + ).toBe(false); + }); + + it("stops at the global budget like everyone else", async () => { + const ledger = new MemoryAiLedger({ + settings: { monthlyBudgetUsd: parseDecimal("0.000001") }, + }); + const { runner } = setup({ ledger, models: visionModels, userId: null }); + + expect( + await codeOf( + runner.runAsSystem({ + action: ALT, + input: { image: new Uint8Array([1]) }, + }), + ), + ).toBe("AI_BUDGET_EXHAUSTED"); + }); +}); + +describe("AiRunner.stream", () => { + const streaming = (chunks: string[]) => + new MockLanguageModelV4({ + doStream: async () => + Promise.resolve({ + stream: convertArrayToReadableStream([ + { id: "t", type: "text-start" }, + ...chunks.map(delta => ({ + delta, + id: "t", + type: "text-delta" as const, + })), + { id: "t", type: "text-end" }, + { + finishReason: { raw: "stop", unified: "stop" }, + type: "finish", + usage: { + inputTokens: { + cacheRead: 0, + cacheWrite: 0, + noCache: 10, + total: 10, + }, + outputTokens: { reasoning: 0, text: 5, total: 5 }, + }, + }, + ]), + }), + }); + + const readAll = async (stream: ReadableStream) => { + let text = ""; + const reader = stream.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) return text; + text += value; + } + }; + + it("streams text and settles once the full answer is valid", async () => { + const { ledger, runner } = setup({ + models: [textModel(streaming(["Hello", ", world"]))], + }); + + const { result, textStream } = await runner.stream({ + action: SUMMARY, + input: { text: "x" }, + }); + + expect(await readAll(textStream)).toBe("Hello, world"); + expect(await result).toMatchObject({ + output: "Hello, world", + usage: { chargedPoints: "20" }, + }); + expect(ledger.runs[0].status).toBe("succeeded"); + }); + + it("charges no points when the reader cancels, and still records the run", async () => { + const { ledger, runner } = setup({ + models: [textModel(streaming(["Hello", " there", " friend"]))], + }); + + const { result, textStream } = await runner.stream({ + action: SUMMARY, + input: { text: "x" }, + }); + const reader = textStream.getReader(); + await reader.read(); + await reader.cancel("user closed the panel"); + + expect(await codeOf(result)).toBe("AI_CANCELED"); + expect(ledger.runs[0]).toMatchObject({ settled: true, status: "canceled" }); + expect(ledger.spentPoints(7)).toBe(0n); + }); +}); diff --git a/packages/vitnode/src/api/lib/ai/runner.ts b/packages/vitnode/src/api/lib/ai/runner.ts new file mode 100644 index 000000000..64db98c76 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/runner.ts @@ -0,0 +1,1098 @@ +import type { + LanguageModel, + LanguageModelUsage, + ModelMessage, + ProviderMetadata, +} from "ai"; +import type { Context } from "hono"; + +import { + APICallError, + generateText, + NoObjectGeneratedError, + NoOutputGeneratedError, + Output, + stepCountIs, + streamText, +} from "ai"; + +import type { AIModelDefinition } from "../../models/ai"; +import type { VitNodeEvents } from "../../models/events"; +import type { + AiActorType, + AiPrompt, + AiResourceRef, + AnyAiActionDefinition, +} from "./action"; +import type { Decimal } from "./decimal"; +import type { AiErrorCode } from "./errors"; +import type { + AiActionSettings, + AiLedger, + AiSettingsSnapshot, + AiSettlement, + AiUserPolicy, +} from "./ledger"; +import type { AiActionRegistry, RegisteredAiAction } from "./registry"; +import type { + AiCost, + AiEffectivePricing, + AiProviderAdapter, + AiUsage, +} from "./usage-cost"; + +import { countInputCharacters } from "./action"; +import { + DEFAULT_AI_MODEL_CAPABILITIES, + missingCapabilities, +} from "./capabilities"; +import { formatDecimal, sumDecimals } from "./decimal"; +import { AiError } from "./errors"; +import { usdToPoints } from "./ledger"; +import { maxCallCost, pricingFingerprint } from "./pricing"; +import { + normalizeUsage, + resolveCost, + resolveProviderAdapter, +} from "./usage-cost"; + +/** Tokens one image may count as, when a model entry does not say. */ +export const DEFAULT_IMAGE_INPUT_TOKENS = 2_000; +/** Fixed prompt overhead (roles, separators) added to every bound. */ +const PROMPT_OVERHEAD_TOKENS = 64; +/** Slack added to an execution lease beyond the timeouts it covers. */ +const LEASE_SLACK_MS = 60_000; + +export interface AiRunRequest { + action: string; + /** A client-supplied key; the same key never runs - or charges - twice. */ + idempotencyKey?: string; + input: unknown; + resource?: AiResourceRef; + signal?: AbortSignal; + /** Fingerprint of the content the input came from, to detect stale results. */ + sourceFingerprint?: string; +} + +/** Safe usage metadata a route may return to the browser. */ +export interface AiRunUsage { + /** Points charged to the user, full precision; `"0"` for system work. */ + chargedPoints: string; + costKnown: boolean; + modelId: string; +} + +export interface AiRunResult { + output: Output; + runId: number; + usage: AiRunUsage; +} + +export interface AiStreamResult { + /** Resolves when the stream ends, with the validated output. Rejects with an `AiError`. */ + result: Promise>; + runId: number; + /** Text deltas. Cancelling it cancels the provider call. */ + textStream: ReadableStream; +} + +interface Actor { + type: AiActorType; + userId: null | number; +} + +interface ResolvedModel { + capabilities: readonly string[]; + definition: AIModelDefinition; + pricing: AiEffectivePricing | null; + provider: string; + providerModelId: string; +} + +interface ExecutionPlan { + action: RegisteredAiAction; + actionSettings: AiActionSettings | null; + actor: Actor; + fallback: null | ResolvedModel; + input: unknown; + maxOutputTokens: number; + maxRetries: number; + maxSteps: number; + policy: AiUserPolicy | null; + primary: ResolvedModel; + prompt: AiPrompt; + promptImages: number; + settings: AiSettingsSnapshot; + timeoutMs: number; +} + +interface CallOutcome { + cost: AiCost; + errorCode: AiErrorCode | null; + images: number; + providerModelId: null | string; + providerRequestId: null | string; + status: "failed" | "succeeded"; + usage: AiUsage; +} + +const UNKNOWN_USAGE: AiUsage = { + cacheReadTokens: null, + cacheWriteTokens: null, + inputTokens: null, + outputTokens: null, + reasoningTokens: null, +}; + +export const providerIdOf = (model: LanguageModel): string => + typeof model === "string" ? "gateway" : model.provider; + +export const providerModelIdOf = (model: LanguageModel): string => + typeof model === "string" ? model : model.modelId; + +const utf8Length = (text: string) => new TextEncoder().encode(text).length; + +/** Text bytes and image parts of a prompt - the input token upper bound. */ +export const measurePrompt = ( + prompt: AiPrompt, +): { bytes: number; images: number } => { + let bytes = utf8Length(prompt.system ?? ""); + let images = 0; + if ("prompt" in prompt) { + bytes += utf8Length(prompt.prompt); + + return { bytes, images }; + } + + for (const message of prompt.messages) { + if (typeof message.content === "string") { + bytes += utf8Length(message.content); + continue; + } + for (const part of message.content) { + if (part.type === "text") bytes += utf8Length(part.text); + else if (part.type === "image") images += 1; + else if (part.type === "file") { + if (part.mediaType.startsWith("image/")) images += 1; + else bytes += typeof part.data === "string" ? utf8Length(part.data) : 0; + } else bytes += utf8Length(JSON.stringify(part)); + } + } + + return { bytes, images }; +}; + +/** + * Runs managed AI actions: authorization, model choice, budgets, provider + * calls, accounting, validation and settlement - in that order, every time. + * Provider calls never run inside a database transaction. + */ +export class AiRunner { + constructor({ + adapters, + c, + ledger, + models, + registry, + }: { + adapters: AiProviderAdapter[]; + c: Context; + ledger: AiLedger; + models: AIModelDefinition[]; + registry: AiActionRegistry; + }) { + this.adapters = adapters; + this.c = c; + this.ledger = ledger; + this.models = models; + this.registry = registry; + } + + private readonly adapters: AiProviderAdapter[]; + private readonly c: Context; + private readonly ledger: AiLedger; + private readonly models: AIModelDefinition[]; + private readonly registry: AiActionRegistry; + + private async callOnce( + plan: ExecutionPlan, + model: ResolvedModel, + runId: number, + attempt: number, + signal: AbortSignal | undefined, + ): Promise<{ outcome: CallOutcome; output?: unknown; text?: string }> { + const { definition } = plan.action; + const startedAt = new Date(); + const callId = await this.ledger.beginCall({ + attempt, + modelId: model.definition.id, + provider: model.provider, + providerModelId: model.providerModelId, + runId, + startedAt, + }); + const abortSignal = combineSignals(signal, plan.timeoutMs); + + try { + const result = await generateText({ + ...promptArgs(plan.prompt), + abortSignal, + maxOutputTokens: plan.maxOutputTokens, + maxRetries: 0, + model: model.definition.model, + stopWhen: stepCountIs(plan.maxSteps), + telemetry: { + isEnabled: false, + recordInputs: false, + recordOutputs: false, + }, + ...(definition.output === "object" + ? { output: Output.object({ schema: definition.outputSchema }) } + : {}), + }); + + // Every step is one billable provider call: the first fills the row + // opened above, later ones get their own. The run sums them once. + const outcomes = result.steps.map(step => + this.describeCall(model, { + images: plan.promptImages, + metadata: step.providerMetadata, + responseId: step.response.id, + responseModelId: step.response.modelId, + usage: step.usage, + }), + ); + const [first, ...rest] = + outcomes.length > 0 + ? outcomes + : [ + this.describeCall(model, { + images: plan.promptImages, + metadata: result.finalStep.providerMetadata, + responseId: result.finalStep.response.id, + responseModelId: result.finalStep.response.modelId, + usage: result.usage, + }), + ]; + await this.ledger.finishCall(callId, { + ...first, + finishedAt: new Date(), + pricingSnapshot: model.pricing?.pricing ?? null, + }); + for (const extra of rest) { + const extraId = await this.ledger.beginCall({ + attempt, + modelId: model.definition.id, + provider: model.provider, + providerModelId: model.providerModelId, + runId, + startedAt, + }); + await this.ledger.finishCall(extraId, { + ...extra, + finishedAt: new Date(), + pricingSnapshot: model.pricing?.pricing ?? null, + }); + } + + return { + outcome: first, + output: definition.output === "object" ? result.output : undefined, + text: result.text, + }; + } catch (error) { + const outcome = this.describeFailure(model, plan, error, signal); + await this.ledger.finishCall(callId, { + ...outcome, + finishedAt: new Date(), + pricingSnapshot: model.pricing?.pricing ?? null, + }); + + return { outcome }; + } + } + + private currentUser(): Actor { + const userId = + this.c.get("admin")?.user.id ?? this.c.get("user")?.id ?? null; + if (userId === null) { + throw new AiError("AI_UNAUTHORIZED", "Sign in to use AI features."); + } + + return { type: "user", userId }; + } + + private describeCall( + model: ResolvedModel, + { + images, + metadata, + responseId, + responseModelId, + usage, + }: { + images: number; + metadata: ProviderMetadata | undefined; + responseId: string | undefined; + responseModelId: string | undefined; + usage: LanguageModelUsage | undefined; + }, + ): CallOutcome { + const adapter = resolveProviderAdapter(this.adapters, model.provider); + const normalized = normalizeUsage(usage); + + return { + cost: resolveCost({ + images, + pricing: model.pricing, + reportedCost: adapter.reportedCost(metadata), + usage: normalized, + }), + errorCode: null, + images, + providerModelId: responseModelId ?? model.providerModelId, + providerRequestId: adapter.requestId({ metadata, responseId }), + status: "succeeded", + usage: normalized, + }; + } + + private describeFailure( + model: ResolvedModel, + plan: ExecutionPlan, + error: unknown, + signal: AbortSignal | undefined, + ): CallOutcome { + // The model answered, but not with a valid object: billed like a success. + if (NoObjectGeneratedError.isInstance(error) && error.usage) { + return { + ...this.describeCall(model, { + images: plan.promptImages, + metadata: undefined, + responseId: error.response?.id, + responseModelId: error.response?.modelId, + usage: error.usage, + }), + errorCode: "AI_INVALID_OUTPUT", + }; + } + + // A definitive error response: the provider generated nothing to bill. + if (APICallError.isInstance(error) && error.statusCode !== undefined) { + return { + cost: { amountUsd: "0", source: "provider" }, + errorCode: "AI_PROVIDER_FAILED", + images: plan.promptImages, + providerModelId: model.providerModelId, + providerRequestId: null, + status: "failed", + usage: { ...UNKNOWN_USAGE, inputTokens: 0, outputTokens: 0 }, + }; + } + + // Aborted, timed out or cut off: the provider may have billed work we + // never saw. Unknown - and so charged in full - never free. + let errorCode: AiErrorCode = "AI_PROVIDER_FAILED"; + if (signal?.aborted) errorCode = "AI_CANCELED"; + else if (isTimeout(error)) errorCode = "AI_TIMEOUT"; + else if (NoOutputGeneratedError.isInstance(error)) + errorCode = "AI_INVALID_OUTPUT"; + + return { + cost: { + amountUsd: null, + reason: "call did not complete", + source: "unknown", + }, + errorCode, + images: plan.promptImages, + providerModelId: model.providerModelId, + providerRequestId: null, + status: "failed", + usage: UNKNOWN_USAGE, + }; + } + + private async emit( + name: K, + payload: VitNodeEvents[K], + ): Promise { + try { + const events = this.c.get("events"); + if (!events) return; + await events.emit(name, payload); + } catch { + /* events never break a run */ + } + } + + private async execute( + request: AiRunRequest, + actor: Actor, + ): Promise> { + const plan = await this.prepare(request, actor); + const runId = await this.reserve(plan, request); + await this.ledger.markRunning( + runId, + new Date(Date.now() + this.leaseMs(plan)), + ); + + let lastError: AiErrorCode = "AI_PROVIDER_FAILED"; + const candidates: { attempts: number; model: ResolvedModel }[] = [ + { attempts: 1 + plan.maxRetries, model: plan.primary }, + ...(plan.fallback ? [{ attempts: 1, model: plan.fallback }] : []), + ]; + let attempt = 0; + + for (const candidate of candidates) { + for (let index = 0; index < candidate.attempts; index++) { + attempt += 1; + const call = await this.callOnce( + plan, + candidate.model, + runId, + attempt, + request.signal, + ); + + if (call.outcome.status === "succeeded") { + const parsed = this.validateOutput( + plan, + call.text ?? "", + call.output, + ); + if (parsed.ok) { + const settled = await this.settle(runId, { + delivered: true, + errorCode: null, + status: "succeeded", + }); + await this.emit("ai.run.completed", { + actionKey: plan.action.key, + actorType: actor.type, + runId, + userId: actor.userId, + }); + + return { + output: parsed.output, + runId, + usage: { + chargedPoints: formatDecimal(settled.chargedPoints), + costKnown: settled.costKnown, + modelId: candidate.model.definition.id, + }, + }; + } + lastError = "AI_INVALID_OUTPUT"; + break; + } + + lastError = call.outcome.errorCode ?? "AI_PROVIDER_FAILED"; + if (lastError === "AI_CANCELED") break; + if (lastError === "AI_INVALID_OUTPUT") break; + } + if (lastError === "AI_CANCELED") break; + } + + await this.settle(runId, { + delivered: false, + errorCode: lastError, + status: lastError === "AI_CANCELED" ? "canceled" : "failed", + }); + await this.emit("ai.run.failed", { + actionKey: plan.action.key, + actorType: actor.type, + errorCode: lastError, + runId, + userId: actor.userId, + }); + + throw new AiError(lastError, failureMessage(lastError), { runId }); + } + + private leaseMs(plan: ExecutionPlan): number { + const attempts = 1 + plan.maxRetries + (plan.fallback ? 1 : 0); + + return plan.timeoutMs * attempts + LEASE_SLACK_MS; + } + + /** Everything up to - not including - the reservation. No money moves here. */ + private async prepare( + request: AiRunRequest, + actor: Actor, + ): Promise { + const action = this.registry.find(request.action); + if (!action) { + throw new AiError( + "AI_ACTION_UNKNOWN", + `AI action "${request.action}" is not registered.`, + ); + } + const { definition } = action; + if (!definition.actors.includes(actor.type)) { + throw new AiError( + "AI_UNAUTHORIZED", + `AI action "${action.key}" cannot run for this actor.`, + ); + } + + const parsedInput = definition.inputSchema.safeParse(request.input); + if (!parsedInput.success) { + throw new AiError("AI_INVALID_INPUT", "The AI input is not valid."); + } + const input: unknown = parsedInput.data; + + if (this.models.length === 0) { + throw new AiError("AI_NOT_CONFIGURED", "No AI models configured"); + } + + const [settings, actionSettings] = await Promise.all([ + this.ledger.loadSettings(), + this.ledger.loadActionSettings(action.key), + ]); + if (!settings.enabled) { + throw new AiError("AI_DISABLED", "AI features are switched off."); + } + if (actionSettings?.enabled === false) { + throw new AiError( + "AI_ACTION_DISABLED", + `AI action "${action.key}" is disabled.`, + ); + } + + let policy: AiUserPolicy | null = null; + if (actor.type === "user" && actor.userId !== null) { + policy = await this.ledger.resolveUserPolicy({ + defaultGranted: definition.permission.defaultGranted, + permissionKey: action.permissionKey, + userId: actor.userId, + }); + if (!policy.granted) { + throw new AiError( + "AI_UNAUTHORIZED", + "You do not have access to this AI feature.", + ); + } + // An AI permission never replaces access to the content itself. + const allowed = definition.authorize + ? await definition.authorize({ + c: this.c, + input: input, + resource: request.resource, + userId: actor.userId, + }) + : false; + if (!allowed) { + throw new AiError( + "AI_UNAUTHORIZED", + "You do not have access to this content.", + ); + } + } + + const defaults = definition.defaults; + const maxInputCharacters = + actionSettings?.maxInputCharacters ?? defaults.maxInputCharacters; + const measured = definition.measureInput + ? definition.measureInput(input) + : countInputCharacters(input); + if (measured > maxInputCharacters) { + throw new AiError( + "AI_INPUT_TOO_LARGE", + `The input is ${measured} characters; the limit is ${maxInputCharacters}.`, + ); + } + + const primary = await this.resolveModel( + definition, + actionSettings?.modelId ?? null, + ); + // An incompatible or removed fallback is skipped, never used blindly. + const fallback = actionSettings?.fallbackModelId + ? await this.resolveModel( + definition, + actionSettings.fallbackModelId, + ).catch(() => null) + : null; + + const prompt = definition.buildPrompt(input, { + instructions: actionSettings?.instructions ?? null, + }); + const promptImages = measurePrompt(prompt).images; + if (promptImages > defaults.maxImages) { + throw new AiError( + "AI_INPUT_TOO_LARGE", + `The input has ${promptImages} images; the limit is ${defaults.maxImages}.`, + ); + } + + return { + action, + actionSettings, + actor, + fallback: + fallback && fallback.definition.id !== primary.definition.id + ? fallback + : null, + input, + maxOutputTokens: + actionSettings?.maxOutputTokens ?? defaults.maxOutputTokens, + maxRetries: actionSettings?.maxRetries ?? defaults.maxRetries, + maxSteps: actionSettings?.maxSteps ?? defaults.maxSteps, + policy, + primary, + prompt, + promptImages, + settings, + timeoutMs: actionSettings?.timeoutMs ?? defaults.timeoutMs, + }; + } + + /** + * The most the whole run can cost: every attempt, every step, and the + * fallback - each at its model's dearest rates with the full output budget. + * `null` when a model has no pricing and so nothing can bound it. + */ + private reservationUsd(plan: ExecutionPlan): Decimal | null { + const { bytes, images } = measurePrompt(plan.prompt); + const runs: { attempts: number; model: ResolvedModel }[] = [ + { attempts: 1 + plan.maxRetries, model: plan.primary }, + ...(plan.fallback ? [{ attempts: 1, model: plan.fallback }] : []), + ]; + const parts: Decimal[] = []; + for (const { attempts, model } of runs) { + if (!model.pricing) return null; + const imageTokens = + images * + (model.definition.imageInputTokens ?? DEFAULT_IMAGE_INPUT_TOKENS); + for (let step = 0; step < plan.maxSteps; step++) { + const perCall = maxCallCost(model.pricing.pricing, { + images, + inputTokens: + bytes + + imageTokens + + PROMPT_OVERHEAD_TOKENS + + step * plan.maxOutputTokens, + outputTokens: plan.maxOutputTokens, + }); + for (let attempt = 0; attempt < attempts; attempt++) + parts.push(perCall); + } + } + + return sumDecimals(parts); + } + + private async reserve( + plan: ExecutionPlan, + request: AiRunRequest, + ): Promise { + const usd = this.reservationUsd(plan); + const userPolicy = plan.policy; + const dailyLimit = + userPolicy?.dailyLimit ?? + plan.actionSettings?.dailyLimit ?? + plan.action.definition.defaults.dailyLimit; + + const result = await this.ledger.reserve({ + actionKey: plan.action.key, + actorType: plan.actor.type, + daily: + plan.actor.type === "user" + ? { limit: dailyLimit, permissionKey: plan.action.permissionKey } + : null, + globalMonthlyUsd: plan.settings.monthlyBudgetUsd, + idempotencyKey: request.idempotencyKey ?? null, + leaseMs: this.leaseMs(plan), + model: { + modelId: plan.primary.definition.id, + provider: plan.primary.provider, + providerModelId: plan.primary.providerModelId, + }, + now: new Date(), + pluginId: plan.action.pluginId, + promptVersion: plan.action.definition.promptVersion, + resource: request.resource, + sourceFingerprint: request.sourceFingerprint ?? null, + systemConcurrency: plan.settings.systemConcurrency, + systemMonthlyUsd: plan.settings.systemMonthlyBudgetUsd, + timeZone: plan.settings.timeZone, + usd, + userConcurrency: plan.settings.userConcurrency, + userId: plan.actor.userId, + userMonthlyPoints: userPolicy?.monthlyPoints ?? null, + userRequestsPerMinute: plan.settings.userRequestsPerMinute, + }); + + if (!result.ok) { + throw new AiError(result.code, failureMessage(result.code), { + resetsAt: result.resetsAt?.toISOString(), + runId: result.runId, + }); + } + + return result.runId; + } + + private async resolveModel( + definition: AnyAiActionDefinition, + modelId: null | string, + ): Promise { + const capabilitiesOf = (entry: AIModelDefinition) => + entry.capabilities ?? DEFAULT_AI_MODEL_CAPABILITIES; + + const entry = modelId + ? this.models.find(model => model.id === modelId) + : this.models.find( + model => + missingCapabilities( + definition.requiredCapabilities, + capabilitiesOf(model), + ).length === 0, + ); + if (!entry) { + throw new AiError( + "AI_MODEL_INCOMPATIBLE", + modelId + ? `AI model "${modelId}" is not configured.` + : `No configured AI model has the capabilities this action needs: ${definition.requiredCapabilities.join(", ")}.`, + ); + } + const missing = missingCapabilities( + definition.requiredCapabilities, + capabilitiesOf(entry), + ); + if (missing.length > 0) { + throw new AiError( + "AI_MODEL_INCOMPATIBLE", + `AI model "${entry.id}" lacks: ${missing.join(", ")}.`, + ); + } + + return { + capabilities: capabilitiesOf(entry), + definition: entry, + pricing: await this.resolvePricing(entry), + provider: providerIdOf(entry.model), + providerModelId: providerModelIdOf(entry.model), + }; + } + + /** Admin override, then synced catalog, then the price in the config. */ + private async resolvePricing( + entry: AIModelDefinition, + ): Promise { + const stored = await this.ledger.loadPricing(entry.id); + if (stored.manual) { + return { + pricing: stored.manual.pricing, + source: "manual", + version: `manual:${stored.manual.id}`, + }; + } + if (stored.sync) { + return { + pricing: stored.sync.pricing, + source: "pricing", + version: `sync:${stored.sync.id}`, + }; + } + if (entry.pricing) { + return { + pricing: entry.pricing, + source: "pricing", + version: `config:${pricingFingerprint(entry.pricing)}`, + }; + } + + return null; + } + + private async settle( + runId: number, + settlement: Omit, + ) { + return await this.ledger.settle(runId, { + ...settlement, + finishedAt: new Date(), + }); + } + + private startStream( + plan: ExecutionPlan, + runId: number, + signal: AbortSignal | undefined, + ): AiStreamResult { + const controller = new AbortController(); + const forward = () => controller.abort(signal?.reason); + signal?.addEventListener("abort", forward, { once: true }); + const model = plan.primary; + let resolveResult!: (value: AiRunResult) => void; + let rejectResult!: (reason: unknown) => void; + const result = new Promise>((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + // A caller that only reads the stream must not see an unhandled rejection. + result.catch(() => undefined); + + let callId: null | number = null; + let text = ""; + let finished = false; + let activeStream: null | ReturnType = null; + let activeReader: null | ReadableStreamDefaultReader = null; + + const finish = async ( + outcome: CallOutcome, + delivered: boolean, + errorCode: AiErrorCode | null, + ) => { + if (finished) return; + finished = true; + signal?.removeEventListener("abort", forward); + if (callId !== null) { + await this.ledger.finishCall(callId, { + ...outcome, + finishedAt: new Date(), + pricingSnapshot: model.pricing?.pricing ?? null, + }); + } + const settled = await this.settle(runId, { + delivered, + errorCode, + status: delivered + ? "succeeded" + : errorCode === "AI_CANCELED" + ? "canceled" + : "failed", + }); + await this.emit(delivered ? "ai.run.completed" : "ai.run.failed", { + actionKey: plan.action.key, + actorType: plan.actor.type, + errorCode, + runId, + userId: plan.actor.userId, + }); + + return settled; + }; + + const textStream = new ReadableStream({ + start: async () => { + await this.ledger.markRunning( + runId, + new Date(Date.now() + this.leaseMs(plan)), + ); + callId = await this.ledger.beginCall({ + attempt: 1, + modelId: model.definition.id, + provider: model.provider, + providerModelId: model.providerModelId, + runId, + startedAt: new Date(), + }); + }, + pull: async streamController => { + try { + if (!activeStream) { + activeStream = streamText({ + ...promptArgs(plan.prompt), + abortSignal: combineSignals(controller.signal, plan.timeoutMs), + maxOutputTokens: plan.maxOutputTokens, + maxRetries: 0, + model: model.definition.model, + telemetry: { + isEnabled: false, + recordInputs: false, + recordOutputs: false, + }, + }); + activeReader = activeStream.textStream.getReader(); + } + const reader = activeReader; + if (!reader) return; + const { done, value } = await reader.read(); + if (!done) { + text += value; + streamController.enqueue(value); + + return; + } + + const stream = activeStream; + const [usage, finalStep] = await Promise.all([ + stream.usage, + stream.finalStep, + ]); + const metadata = finalStep.providerMetadata; + const response = finalStep.response; + const outcome = this.describeCall(model, { + images: plan.promptImages, + metadata, + responseId: response.id, + responseModelId: response.modelId, + usage, + }); + const parsed = this.validateOutput(plan, text, undefined); + if (!parsed.ok) { + await finish(outcome, false, "AI_INVALID_OUTPUT"); + const error = new AiError( + "AI_INVALID_OUTPUT", + failureMessage("AI_INVALID_OUTPUT"), + { runId }, + ); + rejectResult(error); + streamController.error(error); + + return; + } + const settled = await finish(outcome, true, null); + resolveResult({ + output: parsed.output, + runId, + usage: { + chargedPoints: formatDecimal(settled?.chargedPoints ?? 0n), + costKnown: settled?.costKnown ?? false, + modelId: model.definition.id, + }, + }); + streamController.close(); + } catch (error) { + const outcome = this.describeFailure( + model, + plan, + error, + controller.signal, + ); + await finish(outcome, false, outcome.errorCode); + const aiError = new AiError( + outcome.errorCode ?? "AI_PROVIDER_FAILED", + failureMessage(outcome.errorCode ?? "AI_PROVIDER_FAILED"), + { runId }, + ); + rejectResult(aiError); + streamController.error(aiError); + } + }, + cancel: async reason => { + controller.abort(reason); + await finish( + { + cost: { + amountUsd: null, + reason: "canceled mid-stream", + source: "unknown", + }, + errorCode: "AI_CANCELED", + images: plan.promptImages, + providerModelId: model.providerModelId, + providerRequestId: null, + status: "failed", + usage: UNKNOWN_USAGE, + }, + false, + "AI_CANCELED", + ); + rejectResult( + new AiError("AI_CANCELED", failureMessage("AI_CANCELED"), { runId }), + ); + }, + }); + + return { result, runId, textStream }; + } + + private validateOutput( + plan: ExecutionPlan, + text: string, + output: unknown, + ): { ok: false } | { ok: true; output: unknown } { + const { definition } = plan.action; + let candidate: unknown = output; + if (definition.output === "text") { + if (text.trim().length === 0) return { ok: false }; + try { + candidate = definition.parseText(text, plan.input); + } catch { + return { ok: false }; + } + } + const parsed = definition.outputSchema.safeParse(candidate); + + return parsed.success ? { ok: true, output: parsed.data } : { ok: false }; + } + + /** Runs an action for the signed-in user (AdminCP session first). */ + async run(request: AiRunRequest): Promise> { + return await this.execute(request, this.currentUser()); + } + + /** + * Runs an action as the trusted system actor - for cron and queue code only. + * There is no route that reaches this with a client's input. + */ + async runAsSystem(request: AiRunRequest): Promise> { + return await this.execute(request, { type: "system", userId: null }); + } + + /** Streams a text action for the signed-in user. */ + async stream(request: AiRunRequest): Promise> { + const plan = await this.prepare(request, this.currentUser()); + if (plan.action.definition.output !== "text") { + throw new AiError( + "AI_INVALID_INPUT", + `AI action "${plan.action.key}" returns an object and cannot be streamed.`, + ); + } + const runId = await this.reserve(plan, request); + + return this.startStream(plan, runId, request.signal); + } +} + +const promptArgs = ( + prompt: AiPrompt, +): + | { messages: ModelMessage[]; system?: string } + | { prompt: string; system?: string } => + "prompt" in prompt + ? { prompt: prompt.prompt, system: prompt.system } + : { messages: prompt.messages, system: prompt.system }; + +const combineSignals = ( + signal: AbortSignal | undefined, + timeoutMs: number, +): AbortSignal => { + const timeout = AbortSignal.timeout(timeoutMs); + + return signal ? AbortSignal.any([signal, timeout]) : timeout; +}; + +const isTimeout = (error: unknown): boolean => + error instanceof Error && + (error.name === "TimeoutError" || + (error.name === "AbortError" && /timeout/i.test(error.message))); + +const FAILURE_MESSAGES: Partial> = { + AI_BUDGET_EXHAUSTED: "The site's AI budget for this period is used up.", + AI_CANCELED: "The AI request was canceled.", + AI_CONCURRENCY_LIMITED: + "Too many AI requests are running. Try again shortly.", + AI_DAILY_LIMIT_REACHED: "You reached today's limit for this AI feature.", + AI_DUPLICATE_REQUEST: "This AI request was already submitted.", + AI_INVALID_OUTPUT: + "The AI answer was not usable. Nothing was charged to you.", + AI_PRICING_MISSING: + "This AI model has no pricing, so its cost cannot be limited.", + AI_PROVIDER_FAILED: "The AI provider failed. Nothing was charged to you.", + AI_RATE_LIMITED: "Too many AI requests. Try again in a minute.", + AI_TIMEOUT: "The AI provider took too long. Nothing was charged to you.", + AI_USER_LIMIT_REACHED: "You used all your AI points for this period.", +}; + +export const failureMessage = (code: AiErrorCode): string => + FAILURE_MESSAGES[code] ?? "The AI request failed."; + +export const pointsForUsd = (usd: Decimal): string => + formatDecimal(usdToPoints(usd)); diff --git a/packages/vitnode/src/api/lib/ai/usage-cost.ts b/packages/vitnode/src/api/lib/ai/usage-cost.ts new file mode 100644 index 000000000..984c240eb --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/usage-cost.ts @@ -0,0 +1,207 @@ +import type { LanguageModelUsage, ProviderMetadata } from "ai"; + +import type { Decimal } from "./decimal"; +import type { AiPriceEstimate, AiPricing } from "./pricing"; + +import { decimalFromNumber, formatDecimal, parseDecimal } from "./decimal"; +import { priceUsage } from "./pricing"; + +/** Token counts of one provider call. `null` is unknown; `0` is a known zero. */ +export interface AiUsage { + cacheReadTokens: null | number; + cacheWriteTokens: null | number; + inputTokens: null | number; + outputTokens: null | number; + /** Already part of `outputTokens` - shown, never billed twice. */ + reasoningTokens: null | number; +} + +export type AiCostSource = "manual" | "pricing" | "provider" | "unknown"; + +/** + * What one call cost. Discriminated so an unknown cost can never carry an + * amount, and an estimate always names the pricing it came from. + */ +export type AiCost = + | { + amountUsd: null; + reason: string; + source: "unknown"; + } + | { + amountUsd: string; + pricingVersion: string; + source: "manual" | "pricing"; + } + | { + amountUsd: string; + source: "provider"; + }; + +export interface AiUsageCost { + cost: AiCost; + usage: AiUsage; +} + +/** The pricing that applies to one model on its connection, and where it came from. */ +export interface AiEffectivePricing { + pricing: AiPricing; + source: "manual" | "pricing"; + /** Stored with every call so a later price change never rewrites history. */ + version: string; +} + +const known = (value: number | undefined): null | number => + value === undefined || Number.isNaN(value) ? null : value; + +/** + * The AI SDK's usage, with `undefined` turned into `null`. When a provider + * reports only the split of output tokens, the total is their sum. + */ +export const normalizeUsage = ( + usage: LanguageModelUsage | undefined, +): AiUsage => { + if (!usage) { + return { + cacheReadTokens: null, + cacheWriteTokens: null, + inputTokens: null, + outputTokens: null, + reasoningTokens: null, + }; + } + + const text = known(usage.outputTokenDetails.textTokens); + const reasoning = known(usage.outputTokenDetails.reasoningTokens); + const output = + known(usage.outputTokens) ?? + (text !== null ? text + (reasoning ?? 0) : null); + + return { + cacheReadTokens: known(usage.inputTokenDetails.cacheReadTokens), + cacheWriteTokens: known(usage.inputTokenDetails.cacheWriteTokens), + inputTokens: known(usage.inputTokens), + outputTokens: output, + reasoningTokens: reasoning, + }; +}; + +/** + * Provider-specific readers. Only the reading of metadata differs between + * providers; everything after it - pricing, budgets, history - is shared. + */ +export interface AiProviderAdapter { + id: string; + /** + * Looks a request's billed cost up after the fact, for providers that report + * it out of band. Used by reconciliation, never on the request path. + */ + lookupCost?: (requestId: string) => Promise; + matches: (providerId: string) => boolean; + /** The cost the provider itself reported for this call, if any. */ + reportedCost: (metadata: ProviderMetadata | undefined) => Decimal | null; + requestId: (args: { + metadata: ProviderMetadata | undefined; + responseId: string | undefined; + }) => null | string; +} + +const readNumber = (value: unknown): Decimal | null => { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return decimalFromNumber(value); + } + if (typeof value === "string" && /^\d+(\.\d+)?$/.test(value)) { + return parseDecimal(value); + } + + return null; +}; + +const recordOf = (value: unknown): null | Record => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; + +/** + * Vercel AI Gateway (string model ids such as `"anthropic/claude-sonnet-5"`). + * Its metadata carries a `generationId`; some responses also carry `cost`. + * When they do not, the generation's billed cost can be looked up later. + */ +export const gatewayProviderAdapter = ( + lookupCost?: AiProviderAdapter["lookupCost"], +): AiProviderAdapter => ({ + id: "gateway", + lookupCost, + matches: providerId => providerId === "gateway", + reportedCost: metadata => readNumber(recordOf(metadata?.gateway)?.cost), + requestId: ({ metadata, responseId }) => { + const generationId = recordOf(metadata?.gateway)?.generationId; + + return typeof generationId === "string" + ? generationId + : (responseId ?? null); + }, +}); + +/** OpenRouter-compatible providers report `usage.cost` in their metadata. */ +export const openRouterProviderAdapter: AiProviderAdapter = { + id: "openrouter", + matches: providerId => providerId.startsWith("openrouter"), + reportedCost: metadata => + readNumber(recordOf(recordOf(metadata?.openrouter)?.usage)?.cost), + requestId: ({ responseId }) => responseId ?? null, +}; + +/** Direct providers (Anthropic, Google, OpenAI, …) report no cost - pricing estimates it. */ +export const defaultProviderAdapter: AiProviderAdapter = { + id: "default", + matches: () => true, + reportedCost: () => null, + requestId: ({ responseId }) => responseId ?? null, +}; + +export const resolveProviderAdapter = ( + adapters: AiProviderAdapter[], + providerId: string, +): AiProviderAdapter => + adapters.find(adapter => adapter.matches(providerId)) ?? + defaultProviderAdapter; + +/** + * Cost resolution, in order: + * 1. the cost the provider reported for the request; + * 2. the effective pricing of the model on its connection - an admin's + * override replaces the catalog price, it is never ignored; + * 3. unknown. Never zero by default. + */ +export const resolveCost = ({ + images = 0, + pricing, + reportedCost, + usage, +}: { + images?: number; + pricing: AiEffectivePricing | null; + reportedCost: Decimal | null; + usage: AiUsage; +}): AiCost => { + if (reportedCost !== null) { + return { amountUsd: formatDecimal(reportedCost), source: "provider" }; + } + if (!pricing) { + return { amountUsd: null, reason: "no pricing", source: "unknown" }; + } + + const estimate: AiPriceEstimate = priceUsage(pricing.pricing, usage, { + images, + }); + if (estimate.status === "incomplete") { + return { amountUsd: null, reason: estimate.reason, source: "unknown" }; + } + + return { + amountUsd: formatDecimal(estimate.amount), + pricingVersion: pricing.version, + source: pricing.source, + }; +}; diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index 8231705f8..e3656054d 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -17,6 +17,7 @@ import { } from "@/content/registry"; import type { SearchIndexer } from "../models/search"; +import type { AnyAiActionDefinition } from "./ai/action"; import type { CronJobConfig } from "./cron"; import type { RegisteredEditablePage } from "./editable-pages"; import type { EventListenerConfig } from "./events"; @@ -27,6 +28,7 @@ import type { QueueTaskConfig } from "./queue"; import type { WebSocketConfig } from "./websocket"; import { validateSearchIndexers } from "../models/search"; +import { collectAiActions } from "./ai/registry"; import { checkPluginId } from "./check-plugin-id"; import { registerEditablePage, validateEditablePages } from "./editable-pages"; import { collectNavigationPresets } from "./navigation-presets"; @@ -40,6 +42,8 @@ export interface BuildPluginApiReturn< Modules extends readonly BaseBuildModuleReturn

[] = readonly BaseBuildModuleReturn

[], > { + /** AI actions the plugin offers; Core runs, limits and accounts them. */ + aiActions?: AnyAiActionDefinition[]; blocks?: BlockPluginSource; contentModels?: AnyContentModel[]; contentTypes?: AnyContentTypeDefinition[]; @@ -69,6 +73,7 @@ export function buildApiPlugin< const P extends string, const Modules extends readonly BuildModuleReturn[] = readonly [], >({ + aiActions, blocks, editablePages, pluginId, @@ -79,6 +84,7 @@ export function buildApiPlugin< permissionStaff, searchIndexers, }: { + aiActions?: AnyAiActionDefinition[]; blocks?: BlockPluginSource; editablePages?: AnyEditablePageDefinition[]; messages?: LocaleMessagesMap; @@ -107,6 +113,10 @@ export function buildApiPlugin< collectNavigationPresets([{ navigation, pluginId }]); + // Duplicates inside one plugin are refused here, named by that plugin; + // the API boot repeats the check across every plugin. + collectAiActions([{ aiActions, pluginId }]); + const hono = new OpenAPIHono(); const contentModels: AnyContentModel[] = []; const contentTypes: AnyContentTypeDefinition[] = []; @@ -172,6 +182,7 @@ export function buildApiPlugin< return { pluginId, + aiActions, blocks, editablePages: registeredPages.map(entry => entry.page), messages, diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index cdb9b1bb7..cee2a2645 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -40,6 +40,7 @@ import { buildApiMessagesSources } from "@/lib/i18n/sources"; import { resolvePersonalInformationFields } from "@/lib/user-personal-information"; import { realtime } from "@/ws/registry"; +import type { AiActionRegistry } from "../lib/ai/registry"; import type { BuildCronReturn } from "../lib/cron"; import type { RegisteredEditablePage } from "../lib/editable-pages"; import type { EventListenerConfig } from "../lib/events"; @@ -56,6 +57,7 @@ import type { } from "../models/search"; import type { SSOApiPlugin } from "../models/sso"; +import { collectAiActions } from "../lib/ai/registry"; import { resolveClientIp } from "../lib/client-ip"; import { collectCronJobs } from "../lib/cron"; import { @@ -111,6 +113,8 @@ export interface EnvVariablesVitNode { cache: CacheModel; core: { ai?: VitNodeApiConfig["ai"]; + /** Every plugin's AI actions, validated across all plugins at boot. */ + aiActions: AiActionRegistry; authorization: { adminCookieExpires: number; adminCookieName: string; @@ -273,6 +277,9 @@ export const globalMiddleware = ({ const cronMetadata = collectCronJobs(plugins); + // Across *all* plugins: `buildApiPlugin` only sees its own actions. + const aiActionsMetadata = collectAiActions(plugins); + const eventsMetadata: EventListenerConfig[] = plugins.flatMap(plugin => (plugin.events ?? []).map(listener => ({ ...listener, @@ -435,6 +442,7 @@ export const globalMiddleware = ({ c.set("core", { ai, + aiActions: aiActionsMetadata, i18n: i18nMetadata, metadata, email, diff --git a/packages/vitnode/src/api/models/ai.ts b/packages/vitnode/src/api/models/ai.ts index dfdab2f49..54caf731b 100644 --- a/packages/vitnode/src/api/models/ai.ts +++ b/packages/vitnode/src/api/models/ai.ts @@ -3,10 +3,52 @@ import type { Context } from "hono"; import { HTTPException } from "hono/http-exception"; +import type { AiModelCapability } from "../lib/ai/capabilities"; +import type { AiLedger } from "../lib/ai/ledger"; +import type { AiPricing } from "../lib/ai/pricing"; +import type { + AiRunRequest, + AiRunResult, + AiStreamResult, +} from "../lib/ai/runner"; +import type { TypedAiActionKey } from "../lib/ai/registry"; +import type { AiProviderAdapter } from "../lib/ai/usage-cost"; + +import { DEFAULT_AI_MODEL_CAPABILITIES } from "../lib/ai/capabilities"; +import { PostgresAiLedger } from "../lib/ai/postgres-ledger"; +import { AiActionRegistry } from "../lib/ai/registry"; +import { AiRunner, providerIdOf } from "../lib/ai/runner"; +import { + gatewayProviderAdapter, + openRouterProviderAdapter, +} from "../lib/ai/usage-cost"; + +/** A run request whose action key carries its types. */ +export type TypedAiRunRequest = Omit< + AiRunRequest, + "action" | "input" +> & { + action: TypedAiActionKey; + input: Input; +}; + export interface AIModelDefinition { + /** + * What the model can do. Omitted means `["text"]` - vision is never assumed. + * Add `"image-input"` only for a model that accepts images in the prompt. + */ + capabilities?: AiModelCapability[]; id: string; + /** Tokens one input image counts as at most, for reservations. Default 2000. */ + imageInputTokens?: number; model: LanguageModel; name: string; + /** + * The price of this model *on this connection*. A gateway and the direct + * provider bill differently - set the one you actually pay. Admins can + * override it in the AdminCP; without any pricing, costs stay unknown. + */ + pricing?: AiPricing; } /** A text embedding model registered for the `embed`/`embedMany` SDK helpers. */ @@ -30,13 +72,20 @@ export interface AIConfig { imageModels?: AIImageModelDefinition[]; models: AIModelDefinition[]; + /** + * Readers for provider-reported cost and request ids. The AI Gateway and + * OpenRouter readers are built in; add one for another provider. + */ + providerAdapters?: AiProviderAdapter[]; } /** Serializable model info exposed to the client (e.g. via the session API). */ export interface AIPublicModel { + capabilities: AiModelCapability[]; id: string; model: string; name: string; + provider: string; } /** The model's provider id string - the string itself, or a model's `modelId`. */ @@ -44,11 +93,14 @@ const toModelId = (model: string | { modelId: string }): string => typeof model === "string" ? model : model.modelId; export class AIModel { - constructor(c: Context) { + constructor(c: Context, { ledger }: { ledger?: AiLedger } = {}) { this.c = c; + this.ledgerOverride = ledger; } protected readonly c: Context; + private readonly ledgerOverride: AiLedger | undefined; + private runnerInstance: AiRunner | undefined; private config(): AIConfig { const ai = this.c.get("core").ai; @@ -62,6 +114,47 @@ export class AIModel { return ai; } + private runner(): AiRunner { + this.runnerInstance ??= new AiRunner({ + adapters: [ + ...(this.c.get("core")?.ai?.providerAdapters ?? []), + gatewayProviderAdapter(), + openRouterProviderAdapter, + ], + c: this.c, + ledger: this.ledger(), + models: this.c.get("core")?.ai?.models ?? [], + registry: this.actions(), + }); + + return this.runnerInstance; + } + + /** Every registered AI action, from every plugin. */ + actions(): AiActionRegistry { + return this.c.get("core")?.aiActions ?? new AiActionRegistry([]); + } + + /** Capabilities of a configured language model; `["text"]` when undeclared. */ + capabilities(id?: string): AiModelCapability[] { + const entry = this.definition(id); + + return [...(entry.capabilities ?? DEFAULT_AI_MODEL_CAPABILITIES)]; + } + + /** The configured entry of a language model, including its metadata. */ + definition(id?: string): AIModelDefinition { + const { models } = this.config(); + const found = id ? models.find(entry => entry.id === id) : models[0]; + if (!found) { + throw new HTTPException(500, { + message: `AI model "${id}" is not configured.`, + }); + } + + return found; + } + /** * Resolve an embedding model to pass into `embed`/`embedMany`. Omit `id` for * the first configured embedding model. @@ -106,29 +199,58 @@ export class AIModel { return found.model; } + /** Where AI money and history are recorded. */ + ledger(): AiLedger { + return this.ledgerOverride ?? new PostgresAiLedger(this.c.get("db")); + } + /** * Resolve a language model to pass into `generateText`, `streamText`, * `generateObject`, etc. Omit `id` for the default (first) model. */ model(id?: string): LanguageModel { - const { models } = this.config(); - const found = id ? models.find(entry => entry.id === id) : models[0]; - if (!found) { - throw new HTTPException(500, { - message: `AI model "${id}" is not configured.`, - }); - } - - return found.model; + return this.definition(id).model; } models(): AIPublicModel[] { const ai = this.c.get("core").ai; return (ai?.models ?? []).map(entry => ({ + capabilities: [...(entry.capabilities ?? DEFAULT_AI_MODEL_CAPABILITIES)], id: entry.id, model: toModelId(entry.model), name: entry.name, + provider: providerIdOf(entry.model), })); } + + /** + * Runs a registered action for the signed-in user through the shared + * runner: authorization, limits, accounting and validation included. + */ + async run( + request: TypedAiRunRequest, + ): Promise>; + async run(request: AiRunRequest): Promise>; + async run(request: AiRunRequest): Promise> { + return await this.runner().run(request); + } + + /** Runs an action as the trusted system actor. Server code only. */ + async runAsSystem( + request: TypedAiRunRequest, + ): Promise>; + async runAsSystem(request: AiRunRequest): Promise>; + async runAsSystem(request: AiRunRequest): Promise> { + return await this.runner().runAsSystem(request); + } + + /** Streams a text action for the signed-in user. */ + async stream( + request: TypedAiRunRequest, + ): Promise>; + async stream(request: AiRunRequest): Promise>; + async stream(request: AiRunRequest): Promise> { + return await this.runner().stream(request); + } } diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 15e3cce47..2afa3e254 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -8,6 +8,21 @@ import type { SsoProfileField } from "@/lib/sso-profile"; import type { EventListenerConfig } from "../lib/events"; export interface VitNodeEvents { + /** A managed AI run delivered a valid result. */ + "ai.run.completed": { + actionKey: string; + actorType: "system" | "user"; + runId: number; + userId: null | number; + }; + /** A managed AI run ended without a valid result (failure, cancel, invalid output). */ + "ai.run.failed": { + actionKey: string; + actorType: "system" | "user"; + errorCode: null | string; + runId: number; + userId: null | number; + }; "core.page-layout.updated": { changedZones: string[]; pageId: string; diff --git a/packages/vitnode/src/database/ai.ts b/packages/vitnode/src/database/ai.ts new file mode 100644 index 000000000..0ade7003c --- /dev/null +++ b/packages/vitnode/src/database/ai.ts @@ -0,0 +1,360 @@ +import { sql } from "drizzle-orm"; +import { camelCase, index, primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; + +import type { AiPricing } from "@/api/lib/ai/pricing"; + +import { core_roles } from "./roles"; +import { core_users } from "./users"; + +/** USD and AI points: decimal strings with 12 places, never floats. */ +const money = { precision: 24, scale: 12 } as const; + +/** Site-wide AI settings. One row, id 1. */ +export const core_ai_settings = camelCase.table.withRLS( + "core_ai_settings", + t => ({ + id: t.integer().primaryKey().default(1), + /** Global switch: off refuses every new paid operation. */ + enabled: t.boolean().notNull().default(true), + /** Monthly site budget in USD. `null` means no site cap. */ + monthlyBudgetUsd: t.numeric(money), + /** Optional extra cap on system work (automatic ALT). `null` = off. */ + systemMonthlyBudgetUsd: t.numeric(money), + /** Points a user gets when no role policy gives them any. */ + defaultMonthlyPoints: t.numeric(money).notNull().default("0"), + /** Point conversion in force. Version 1: 1 point = 0.001 USD. */ + pointsConversionVersion: t.integer().notNull().default(1), + /** Runs started per user per minute. */ + userRequestsPerMinute: t.integer().notNull().default(10), + /** Runs in flight per user. */ + userConcurrency: t.integer().notNull().default(2), + /** Runs in flight for the system actor. */ + systemConcurrency: t.integer().notNull().default(2), + /** How long run history is kept, independently of the queue. */ + historyRetentionDays: t.integer().notNull().default(365), + /** Automatic ALT text for images in core_files. Off until an admin turns it on. */ + altEnabled: t.boolean().notNull().default(false), + /** Language codes to generate ALT for; `null` means every site language. */ + altLanguages: t.jsonb().$type(), + /** Images analysed per cron run - bounds the backfill. */ + altBatchSize: t.integer().notNull().default(10), + /** Keyset cursor of the ALT detection sweep. */ + altScanCursor: t.integer().notNull().default(0), + altScanStartedAt: t.timestamp(), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), +); + +/** Per-action admin settings. A missing row means the definition's defaults. */ +export const core_ai_action_settings = camelCase.table.withRLS( + "core_ai_action_settings", + t => ({ + actionKey: t.varchar({ length: 255 }).primaryKey(), + enabled: t.boolean().notNull().default(true), + modelId: t.varchar({ length: 100 }), + fallbackModelId: t.varchar({ length: 100 }), + maxInputCharacters: t.integer(), + maxOutputTokens: t.integer(), + timeoutMs: t.integer(), + maxRetries: t.integer(), + maxSteps: t.integer(), + dailyLimit: t.integer(), + instructions: t.text(), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), +); + +/** + * Admin-managed pricing, one immutable row per version. Changing a price adds + * a row and deactivates the old one, so every stored call keeps the price it + * was charged at. + */ +export const core_ai_pricing = camelCase.table.withRLS( + "core_ai_pricing", + t => ({ + id: t.serial().primaryKey(), + modelId: t.varchar({ length: 100 }).notNull(), + source: t + .varchar({ enum: ["manual", "sync"], length: 20 }) + .notNull() + .default("manual"), + pricing: t.jsonb().$type().notNull(), + active: t.boolean().notNull().default(true), + createdById: t.integer().references(() => core_users.id, { + onDelete: "set null", + }), + createdAt: t.timestamp().notNull().defaultNow(), + }), + t => [ + uniqueIndex("core_ai_pricing_active_unique") + .on(t.modelId, t.source) + .where(sql`active`), + ], +); + +/** A role's individual monthly allowance. Never a shared pool. */ +export const core_ai_role_policies = camelCase.table.withRLS( + "core_ai_role_policies", + t => ({ + roleId: t + .integer() + .primaryKey() + .references(() => core_roles.id, { onDelete: "cascade" }), + monthlyPoints: t.numeric(money), + unlimited: t.boolean().notNull().default(false), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), +); + +/** Whether a role may use an AI permission, and its daily invocation limit. */ +export const core_ai_role_permissions = camelCase.table.withRLS( + "core_ai_role_permissions", + t => ({ + roleId: t + .integer() + .notNull() + .references(() => core_roles.id, { onDelete: "cascade" }), + permission: t.varchar({ length: 255 }).notNull(), + granted: t.boolean().notNull().default(true), + dailyLimit: t.integer(), + }), + t => [primaryKey({ columns: [t.roleId, t.permission] })], +); + +/** An explicit exception for one user. Replaces what their roles give. */ +export const core_ai_user_overrides = camelCase.table.withRLS( + "core_ai_user_overrides", + t => ({ + userId: t + .integer() + .primaryKey() + .references(() => core_users.id, { onDelete: "cascade" }), + monthlyPoints: t.numeric(money), + unlimited: t.boolean().notNull().default(false), + blocked: t.boolean().notNull().default(false), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), +); + +export const AI_RUN_STATUSES = [ + "reserved", + "running", + "succeeded", + "failed", + "canceled", + "uncertain", +] as const; + +/** One user or system operation. A run can hold several provider calls. */ +export const core_ai_runs = camelCase.table.withRLS( + "core_ai_runs", + t => ({ + id: t.serial().primaryKey(), + actionKey: t.varchar({ length: 255 }).notNull(), + pluginId: t.varchar({ length: 100 }).notNull(), + actorType: t.varchar({ enum: ["system", "user"], length: 10 }).notNull(), + userId: t.integer().references(() => core_users.id, { + onDelete: "set null", + }), + /** Client-supplied key; the same key never runs - or charges - twice. */ + idempotencyKey: t.varchar({ length: 255 }), + resourceType: t.varchar({ length: 100 }), + resourceId: t.varchar({ length: 255 }), + status: t.varchar({ enum: AI_RUN_STATUSES, length: 20 }).notNull(), + errorCode: t.varchar({ length: 64 }), + promptVersion: t.integer().notNull(), + pointsConversionVersion: t.integer().notNull(), + modelId: t.varchar({ length: 100 }), + provider: t.varchar({ length: 100 }), + providerModelId: t.varchar({ length: 255 }), + inputTokens: t.integer(), + outputTokens: t.integer(), + cacheReadTokens: t.integer(), + cacheWriteTokens: t.integer(), + reasoningTokens: t.integer(), + /** Known cost of every call, or `null` while any call's cost is unknown. */ + costUsd: t.numeric(money), + costSource: t.varchar({ + enum: ["manual", "pricing", "provider", "unknown", "mixed"], + length: 20, + }), + /** What the budgets were charged - the known cost, or the reservation when unknown. */ + chargedUsd: t.numeric(money), + chargedPoints: t.numeric(money), + reservedUsd: t.numeric(money).notNull().default("0"), + reservedPoints: t.numeric(money).notNull().default("0"), + settlement: t + .varchar({ enum: ["pending", "settled", "reconciling"], length: 20 }) + .notNull() + .default("pending"), + /** Execution lease. Expiry never releases money - it marks the run uncertain. */ + leaseExpiresAt: t.timestamp(), + /** Suggestion tracking; never a billing trigger. */ + accepted: t.boolean(), + acceptedAt: t.timestamp(), + sourceFingerprint: t.varchar({ length: 64 }), + createdAt: t.timestamp().notNull().defaultNow(), + startedAt: t.timestamp(), + finishedAt: t.timestamp(), + settledAt: t.timestamp(), + }), + t => [ + index("core_ai_runs_user_created_idx").on(t.userId, t.createdAt), + index("core_ai_runs_created_idx").on(t.createdAt, t.id), + index("core_ai_runs_action_created_idx").on(t.actionKey, t.createdAt), + index("core_ai_runs_status_idx").on(t.status, t.leaseExpiresAt), + uniqueIndex("core_ai_runs_idempotency_unique") + .on(t.actorType, t.userId, t.idempotencyKey) + .where(sql`"idempotencyKey" IS NOT NULL`), + ], +); + +/** One billable provider call inside a run. */ +export const core_ai_calls = camelCase.table.withRLS( + "core_ai_calls", + t => ({ + id: t.serial().primaryKey(), + runId: t + .integer() + .notNull() + .references(() => core_ai_runs.id, { onDelete: "cascade" }), + attempt: t.integer().notNull().default(1), + modelId: t.varchar({ length: 100 }).notNull(), + provider: t.varchar({ length: 100 }).notNull(), + providerModelId: t.varchar({ length: 255 }), + providerRequestId: t.varchar({ length: 255 }), + status: t + .varchar({ enum: ["succeeded", "failed", "uncertain"], length: 20 }) + .notNull(), + errorCode: t.varchar({ length: 64 }), + inputTokens: t.integer(), + outputTokens: t.integer(), + cacheReadTokens: t.integer(), + cacheWriteTokens: t.integer(), + reasoningTokens: t.integer(), + images: t.integer().notNull().default(0), + costUsd: t.numeric(money), + costSource: t + .varchar({ + enum: ["manual", "pricing", "provider", "unknown"], + length: 20, + }) + .notNull(), + costReason: t.varchar({ length: 255 }), + pricingVersion: t.varchar({ length: 100 }), + pricingSnapshot: t.jsonb().$type(), + startedAt: t.timestamp().notNull(), + finishedAt: t.timestamp(), + reconciledAt: t.timestamp(), + }), + t => [ + index("core_ai_calls_run_idx").on(t.runId), + index("core_ai_calls_reconcile_idx").on(t.costSource, t.reconciledAt), + ], +); + +/** Auditable correction of a call's cost: an estimate replaced by the billed amount. */ +export const core_ai_cost_adjustments = camelCase.table.withRLS( + "core_ai_cost_adjustments", + t => ({ + id: t.serial().primaryKey(), + callId: t + .integer() + .notNull() + .references(() => core_ai_calls.id, { onDelete: "cascade" }), + runId: t + .integer() + .notNull() + .references(() => core_ai_runs.id, { onDelete: "cascade" }), + previousCostUsd: t.numeric(money), + previousSource: t.varchar({ length: 20 }).notNull(), + newCostUsd: t.numeric(money).notNull(), + newSource: t.varchar({ length: 20 }).notNull(), + /** Budget delta applied: new charge minus old charge. */ + deltaUsd: t.numeric(money).notNull(), + reason: t.varchar({ length: 255 }).notNull(), + createdAt: t.timestamp().notNull().defaultNow(), + }), + t => [uniqueIndex("core_ai_cost_adjustments_call_unique").on(t.callId)], +); + +/** + * Every budget and counter, one row per scope per period: + * - `global` (USD), `system` (USD), + * - `user:` (points), + * - `user::daily:` (invocations). + * Reservations lock these rows in `scopeKey` order. + */ +export const core_ai_budget_periods = camelCase.table.withRLS( + "core_ai_budget_periods", + t => ({ + id: t.serial().primaryKey(), + scopeKey: t.varchar({ length: 255 }).notNull(), + unit: t.varchar({ enum: ["count", "points", "usd"], length: 10 }).notNull(), + periodStart: t.timestamp().notNull(), + periodEnd: t.timestamp().notNull(), + /** `null` means no cap in this period. */ + limitAmount: t.numeric(money), + spentAmount: t.numeric(money).notNull().default("0"), + reservedAmount: t.numeric(money).notNull().default("0"), + /** Operations whose cost stayed unknown and were charged their reservation. */ + unknownCount: t.integer().notNull().default(0), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + t => [ + uniqueIndex("core_ai_budget_periods_scope_unique").on( + t.scopeKey, + t.periodStart, + ), + ], +); + +/** What one run holds against each budget until it settles. */ +export const core_ai_reservations = camelCase.table.withRLS( + "core_ai_reservations", + t => ({ + id: t.serial().primaryKey(), + runId: t + .integer() + .notNull() + .references(() => core_ai_runs.id, { onDelete: "cascade" }), + budgetPeriodId: t + .integer() + .notNull() + .references(() => core_ai_budget_periods.id, { onDelete: "cascade" }), + amount: t.numeric(money).notNull(), + status: t + .varchar({ enum: ["active", "settled"], length: 10 }) + .notNull() + .default("active"), + createdAt: t.timestamp().notNull().defaultNow(), + settledAt: t.timestamp(), + }), + t => [ + uniqueIndex("core_ai_reservations_run_period_unique").on( + t.runId, + t.budgetPeriodId, + ), + index("core_ai_reservations_status_idx").on(t.status), + ], +); diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts index 1482e3c0c..c92c79eb4 100644 --- a/packages/vitnode/src/database/relations.ts +++ b/packages/vitnode/src/database/relations.ts @@ -1,6 +1,7 @@ import { defineRelations } from "drizzle-orm"; import * as admins from "./admins"; +import * as ai from "./ai"; import * as content from "./content"; import * as cron from "./cron"; import * as dashboard from "./dashboard"; @@ -20,6 +21,7 @@ import * as users from "./users"; export const coreSchema = { ...admins, + ...ai, ...content, ...cron, ...dashboard, diff --git a/packages/vitnode/src/tests/postgres.ts b/packages/vitnode/src/tests/postgres.ts index ee5e81296..710a19ee5 100644 --- a/packages/vitnode/src/tests/postgres.ts +++ b/packages/vitnode/src/tests/postgres.ts @@ -6,13 +6,17 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import { describe } from "vitest"; +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; + +import { coreRelations, coreSchema } from "@/database/relations"; + const TEST_POSTGRES_URL = process.env.VITNODE_TEST_POSTGRES_URL; export const describePostgres = (name: string, body: () => void): void => { describe.skipIf(!TEST_POSTGRES_URL)(name, body); }; -type TestDatabase = ReturnType; +type TestDatabase = EnvVariablesVitNode["db"]; export interface TestDatabaseHandle { connect: () => TestDatabase; @@ -28,7 +32,7 @@ const withDatabase = (url: string, database: string): string => { }; export const createTestDatabase = async ( - schema: Record, + schema: Record = coreSchema, { onQuery }: { onQuery?: () => void } = {}, ): Promise => { if (!TEST_POSTGRES_URL) { @@ -57,7 +61,7 @@ export const createTestDatabase = async ( const connect = (): TestDatabase => { const client = postgres(url, { connection: { TimeZone: "UTC" }, - max: 10, + max: 20, onnotice: () => {}, }); clients.push(client); @@ -65,6 +69,7 @@ export const createTestDatabase = async ( return drizzle({ client, logger: onQuery ? { logQuery: () => onQuery() } : undefined, + relations: coreRelations, }); }; @@ -72,13 +77,15 @@ export const createTestDatabase = async ( connect, db: connect(), drop: async () => { - await Promise.all(clients.map(async client => await client.end())); + await Promise.all( + clients.map(async client => await client.end({ timeout: 5 })), + ); const cleanup = postgres(TEST_POSTGRES_URL, { max: 1, onnotice: () => {}, }); await cleanup.unsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`); - await cleanup.end(); + await cleanup.end({ timeout: 5 }); }, }; }; diff --git a/plugins/blog/src/api/ai/actions.ts b/plugins/blog/src/api/ai/actions.ts new file mode 100644 index 000000000..75347eee3 --- /dev/null +++ b/plugins/blog/src/api/ai/actions.ts @@ -0,0 +1,106 @@ +import { defineAiAction } from "@vitnode/core/api/lib/ai/action"; +import { assertSameHtmlStructure } from "@vitnode/core/api/lib/ai/html-structure"; +import { aiActionRef } from "@vitnode/core/api/lib/ai/registry"; +import { checkStaffPermission } from "@vitnode/core/api/lib/check-staff-permission"; +import { z } from "zod"; + +import { + buildExcerptPrompt, + buildTranslatePrompt, + excerptSource, + unquote, +} from "@/api/lib/ai-writing"; +import { CONFIG_PLUGIN } from "@/const"; + +const zodLocale = z.string().min(2).max(16); + +export const zodTranslateAiSchema = z.object({ + format: z.enum(["html", "text"]), + from: zodLocale, + text: z.string().trim().min(1).max(100_000), + to: zodLocale, +}); + +export const zodExcerptAiSchema = z.object({ + content: z.string().trim().min(1).max(200_000), + locale: zodLocale, + title: z.string().trim().min(1).max(255), +}); + +/** + * Articles are edited by staff who may edit posts. The AI permission adds to + * that check - it never replaces it. + */ +const canEditPosts = async ({ + c, +}: { + c: Parameters[0]; +}) => + await checkStaffPermission(c, { + module: "posts", + permission: "can_edit", + plugin: CONFIG_PLUGIN.pluginId, + type: "admin", + }); + +export const translateFieldAiAction = defineAiAction({ + authorize: canEditPosts, + buildPrompt: (input, { instructions }) => + buildTranslatePrompt(input, instructions), + defaults: { + maxInputCharacters: 100_000, + maxOutputTokens: 32_000, + timeoutMs: 120_000, + }, + description: "Translates one field of an article into another language.", + id: "field.translate", + inputSchema: zodTranslateAiSchema, + measureInput: input => input.text.length, + output: "text", + outputSchema: z.string().min(1), + // Rich text keeps its structure or is refused: links, attributes and blocks + // are compared tag by tag, not trusted to the prompt. + parseText: (text, input) => { + if (input.format === "text") return unquote(text); + const translated = text.trim(); + assertSameHtmlStructure(input.text, translated); + + return translated; + }, + permission: { defaultGranted: true, key: "translate" }, + promptVersion: 1, + requiredCapabilities: ["text"], +}); + +export const excerptAiAction = defineAiAction({ + authorize: canEditPosts, + buildPrompt: (input, { instructions }) => + buildExcerptPrompt(input, instructions), + defaults: { + maxInputCharacters: 12_500, + maxOutputTokens: 300, + timeoutMs: 30_000, + }, + description: "Writes a short excerpt for an article.", + id: "excerpt.generate", + inputSchema: zodExcerptAiSchema, + measureInput: input => + input.title.length + excerptSource(input.content).length, + output: "text", + outputSchema: z.string().min(1).max(300), + parseText: text => unquote(text), + permission: { defaultGranted: true, key: "excerpt" }, + promptVersion: 1, + requiredCapabilities: ["text"], +}); + +export const blogAiActions = [translateFieldAiAction, excerptAiAction]; + +export const TRANSLATE_FIELD_AI_ACTION = aiActionRef( + CONFIG_PLUGIN.pluginId, + translateFieldAiAction, +); +export const EXCERPT_AI_ACTION = aiActionRef( + CONFIG_PLUGIN.pluginId, + excerptAiAction, +); diff --git a/plugins/blog/src/api/lib/ai-writing.ts b/plugins/blog/src/api/lib/ai-writing.ts index 55e43f297..5a65efebc 100644 --- a/plugins/blog/src/api/lib/ai-writing.ts +++ b/plugins/blog/src/api/lib/ai-writing.ts @@ -1,11 +1,12 @@ -import type { LanguageModel } from "ai"; +import type { AiPrompt } from "@vitnode/core/api/lib/ai/action"; import { stripHtml } from "@vitnode/core/lib/strip-html"; -import { generateText } from "ai"; export type AiTextFormat = "html" | "text"; export const EXCERPT_RECOMMENDED_LENGTH = 160; +/** How much article text the excerpt prompt reads. */ +export const EXCERPT_SOURCE_CHARACTERS = 12_000; const languageName = (locale: string) => { try { @@ -17,29 +18,26 @@ const languageName = (locale: string) => { } }; -const unquote = (text: string) => +export const unquote = (text: string) => text .trim() .replace(/^["“„'](.*)["”'"]$/su, "$1") .trim(); -export const translateWithAi = async ({ - format, - from, - model, - text, - to, -}: { - format: AiTextFormat; - from: string; - model: LanguageModel; - text: string; - to: string; -}): Promise => { - const { text: output } = await generateText({ - model, - temperature: 0, - system: [ +const withInstructions = (lines: string[], instructions: null | string) => + [...lines, ...(instructions ? [instructions] : [])].join("\n"); + +export const buildTranslatePrompt = ( + { + format, + from, + text, + to, + }: { format: AiTextFormat; from: string; text: string; to: string }, + instructions: null | string = null, +): AiPrompt => ({ + system: withInstructions( + [ "You translate fields of a blog article for a content management system.", `Translate from ${languageName(from)} into ${languageName(to)}.`, format === "html" @@ -47,36 +45,32 @@ export const translateWithAi = async ({ : "The input is plain text. Answer with plain text only.", "Keep product names, code, URLs and numbers unchanged.", "Answer with the translation only, without quotes, notes or explanations.", - ].join("\n"), - prompt: text, - }); + ], + instructions, + ), + prompt: text, +}); - return format === "html" ? output.trim() : unquote(output); -}; +export const excerptSource = (content: string) => + stripHtml(content).slice(0, EXCERPT_SOURCE_CHARACTERS); -export const writeExcerptWithAi = async ({ - content, - locale, - model, - title, -}: { - content: string; - locale: string; - model: LanguageModel; - title: string; -}): Promise => { - const { text } = await generateText({ - model, - temperature: 0.3, - system: [ +export const buildExcerptPrompt = ( + { + content, + locale, + title, + }: { content: string; locale: string; title: string }, + instructions: null | string = null, +): AiPrompt => ({ + system: withInstructions( + [ "You write the excerpt of a blog article: one or two sentences shown on the blog list and as the search result description.", `Write it in ${languageName(locale)}.`, `Keep it under ${EXCERPT_RECOMMENDED_LENGTH} characters.`, "Make it specific to the article, never generic, and do not start with the title.", "Answer with the excerpt only, without quotes, notes or explanations.", - ].join("\n"), - prompt: `Title: ${title}\n\nArticle:\n${stripHtml(content).slice(0, 12_000)}`, - }); - - return unquote(text); -}; + ], + instructions, + ), + prompt: `Title: ${title}\n\nArticle:\n${excerptSource(content)}`, +}); diff --git a/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts b/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts index c998886f6..40586eb23 100644 --- a/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts +++ b/plugins/blog/src/api/modules/admin/ai/ai.admin.module.test.ts @@ -3,11 +3,16 @@ import type { CacheClient } from "@vitnode/core/api/lib/cache"; import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; +import { MemoryAiLedger } from "@vitnode/core/api/lib/ai/memory-ledger"; +import { collectAiActions } from "@vitnode/core/api/lib/ai/registry"; import { CacheModel } from "@vitnode/core/api/lib/cache"; import { writeStaffPermissions } from "@vitnode/core/api/lib/staff-permission-cache"; +import { AIModel } from "@vitnode/core/api/models/ai"; import { MockLanguageModelV4 } from "ai/test"; import { describe, expect, it } from "vitest"; +import { blogAiActions } from "@/api/ai/actions"; + import { excerptAiAdminRoute } from "./routes/excerpt.route"; import { translateAiAdminRoute } from "./routes/translate.route"; @@ -75,14 +80,19 @@ const modelAnswering = (text: string) => const createApp = async ({ canEdit = true, configured = true, + ledger = new MemoryAiLedger(), model = modelAnswering("Cześć"), }: { canEdit?: boolean; configured?: boolean; + ledger?: MemoryAiLedger; model?: MockLanguageModelV4; } = {}) => { const cache = createCache(); await grant(cache, canEdit); + const aiActions = collectAiActions([ + { aiActions: blogAiActions, pluginId: "@vitnode/blog" }, + ]); const app = new OpenAPIHono(); app.use("*", async (c, next) => { @@ -90,15 +100,31 @@ const createApp = async ({ c.set("cache" as never, cache as never); c.set( "core" as never, - { ai: configured ? { models: [{ id: "default" }] } : undefined } as never, + { + ai: configured + ? { + models: [ + { + id: "default", + model, + name: "Test", + pricing: { + rates: { inputPerMillion: "3", outputPerMillion: "15" }, + }, + }, + ], + } + : undefined, + aiActions, + } as never, ); - c.set("ai" as never, { model: () => model } as never); + c.set("ai" as never, new AIModel(c as Context, { ledger }) as never); await next(); }); app.openapi(translateAiAdminRoute.route, translateAiAdminRoute.handler); app.openapi(excerptAiAdminRoute.route, excerptAiAdminRoute.handler); - return { app, model }; + return { app, ledger, model }; }; const post = async (app: OpenAPIHono, path: string, body: unknown) => @@ -196,4 +222,63 @@ describe("blog AI admin routes", () => { expect(response.status).toBe(400); expect(model.doGenerateCalls).toHaveLength(0); }); + + it("runs through the shared runner and records the run", async () => { + const { app, ledger } = await createApp({ + model: modelAnswering("Cześć"), + }); + + await post(app, "/translate", { + format: "text", + from: "en", + text: "Hello", + to: "pl", + }); + + expect(ledger.runs).toMatchObject([ + { + actionKey: "@vitnode/blog:field.translate", + status: "succeeded", + userId: EDITOR_ID, + }, + ]); + }); + + it("refuses a rich-text translation that changed the document structure", async () => { + const { app } = await createApp({ + model: modelAnswering( + '

Cześć link

', + ), + }); + + const response = await post(app, "/translate", { + format: "html", + from: "en", + text: '

Hello link

', + to: "pl", + }); + + expect(response.status).toBe(502); + expect(await response.json()).toMatchObject({ code: "AI_INVALID_OUTPUT" }); + }); + + it("answers 429 with a stable code when the editor's points are used up", async () => { + const { app, model } = await createApp({ + ledger: new MemoryAiLedger({ + policy: () => ({ dailyLimit: null, granted: true, monthlyPoints: 0n }), + }), + }); + + const response = await post(app, "/excerpt", { + content: "

Body

", + locale: "en", + title: "Title", + }); + + expect(response.status).toBe(429); + expect(await response.json()).toMatchObject({ + code: "AI_USER_LIMIT_REACHED", + }); + expect(model.doGenerateCalls).toHaveLength(0); + }); }); diff --git a/plugins/blog/src/api/modules/admin/ai/routes/excerpt.route.ts b/plugins/blog/src/api/modules/admin/ai/routes/excerpt.route.ts index bcd4505e2..85b2ec9a9 100644 --- a/plugins/blog/src/api/modules/admin/ai/routes/excerpt.route.ts +++ b/plugins/blog/src/api/modules/admin/ai/routes/excerpt.route.ts @@ -1,16 +1,9 @@ import { buildRoute } from "@vitnode/core/api/lib/route"; -import { HTTPException } from "hono/http-exception"; import { z } from "zod"; -import { writeExcerptWithAi } from "@/api/lib/ai-writing"; +import { EXCERPT_AI_ACTION, zodExcerptAiSchema } from "@/api/ai/actions"; import { CONFIG_PLUGIN } from "@/const"; -export const zodExcerptAiSchema = z.object({ - content: z.string().trim().min(1).max(200_000), - locale: z.string().min(2).max(16), - title: z.string().trim().min(1).max(255), -}); - export const excerptAiAdminRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, adminStaffPermission: { module: "posts", permission: "can_edit" }, @@ -32,22 +25,19 @@ export const excerptAiAdminRoute = buildRoute({ }, description: "The written excerpt", }, - 400: { description: "No AI model is configured" }, + 400: { description: "No AI model is configured or the input is invalid" }, + 403: { description: "No access to posts or to this AI feature" }, + 429: { description: "A personal AI limit was reached" }, + 502: { description: "The AI provider failed or answered unusably" }, + 503: { description: "AI is switched off or the site budget is used up" }, }, }, handler: async c => { - if (!c.get("core").ai?.models.length) { - throw new HTTPException(400, { message: "No AI models configured" }); - } - - const { content, locale, title } = c.req.valid("json"); - const excerpt = await writeExcerptWithAi({ - content, - locale, - model: c.get("ai").model(), - title, + const { output } = await c.get("ai").run({ + action: EXCERPT_AI_ACTION, + input: c.req.valid("json"), }); - return c.json({ text: excerpt }, 200); + return c.json({ text: output }, 200); }, }); diff --git a/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts b/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts index 2ce6080ec..2c0a458aa 100644 --- a/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts +++ b/plugins/blog/src/api/modules/admin/ai/routes/translate.route.ts @@ -1,19 +1,12 @@ import { buildRoute } from "@vitnode/core/api/lib/route"; -import { HTTPException } from "hono/http-exception"; import { z } from "zod"; -import { translateWithAi } from "@/api/lib/ai-writing"; +import { + TRANSLATE_FIELD_AI_ACTION, + zodTranslateAiSchema, +} from "@/api/ai/actions"; import { CONFIG_PLUGIN } from "@/const"; -const zodLocale = z.string().min(2).max(16); - -export const zodTranslateAiSchema = z.object({ - format: z.enum(["html", "text"]), - from: zodLocale, - text: z.string().trim().min(1).max(100_000), - to: zodLocale, -}); - export const translateAiAdminRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, adminStaffPermission: { module: "posts", permission: "can_edit" }, @@ -35,23 +28,19 @@ export const translateAiAdminRoute = buildRoute({ }, description: "The translated text", }, - 400: { description: "No AI model is configured" }, + 400: { description: "No AI model is configured or the input is invalid" }, + 403: { description: "No access to posts or to this AI feature" }, + 429: { description: "A personal AI limit was reached" }, + 502: { description: "The AI provider failed or answered unusably" }, + 503: { description: "AI is switched off or the site budget is used up" }, }, }, handler: async c => { - if (!c.get("core").ai?.models.length) { - throw new HTTPException(400, { message: "No AI models configured" }); - } - - const { format, from, text, to } = c.req.valid("json"); - const translated = await translateWithAi({ - format, - from, - model: c.get("ai").model(), - text, - to, + const { output } = await c.get("ai").run({ + action: TRANSLATE_FIELD_AI_ACTION, + input: c.req.valid("json"), }); - return c.json({ text: translated }, 200); + return c.json({ text: output }, 200); }, }); diff --git a/plugins/blog/src/config.api.ts b/plugins/blog/src/config.api.ts index 5e5404c0e..743c253b7 100644 --- a/plugins/blog/src/config.api.ts +++ b/plugins/blog/src/config.api.ts @@ -3,6 +3,7 @@ import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin"; import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; import { buildContentPublicModule } from "@vitnode/core/content/server"; +import { blogAiActions } from "@/api/ai/actions"; import { adminModule } from "@/api/modules/admin/admin.module"; import { CONFIG_PLUGIN } from "@/const"; import { categoryContent } from "@/database/categories"; @@ -11,6 +12,7 @@ import { postContent } from "@/database/posts"; export const blogApiPlugin = () => buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, + aiActions: blogAiActions, modules: [ adminModule, buildContentPublicModule({ From ee9e5d5e100724d68633f3c027f98c3d21c309af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:08:07 +0000 Subject: [PATCH 02/14] feat(ai): AdminCP and account AI APIs, maintenance cron and docs Adds admin routes for overview, settings, models and pricing overrides, gateway price sync, action configuration and test runs, role/user access, and run history; user routes for own usage, history and suggestion feedback; a generic run route; and an ai-maintenance cron that settles expired leases, reconciles billed costs with audited adjustments and prunes history. Users are charged only for the call that delivered. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR --- apps/web/content/docs/dev/ai/actions.mdx | 512 +++++++++++++++ .../content/docs/dev/ai/costs-and-limits.mdx | 331 ++++++++++ apps/web/content/docs/dev/ai/index.mdx | 169 ++++- apps/web/content/docs/dev/ai/meta.json | 4 +- apps/web/content/docs/dev/ai/usage.mdx | 69 +- .../docs/dev/events/built-in-events.mdx | 18 + apps/web/src/locales/@vitnode/core/pl.json | 3 + .../vitnode/src/api/lib/ai/accounting.test.ts | 21 +- .../vitnode/src/api/lib/ai/admin-stats.ts | 208 ++++++ packages/vitnode/src/api/lib/ai/budget.ts | 23 +- .../vitnode/src/api/lib/ai/maintenance.ts | 377 +++++++++++ .../vitnode/src/api/lib/ai/memory-ledger.ts | 16 +- .../ai/postgres-ledger.integration.test.ts | 617 ++++++++++-------- .../vitnode/src/api/lib/ai/postgres-ledger.ts | 8 + packages/vitnode/src/api/lib/ai/registry.ts | 4 +- .../vitnode/src/api/lib/ai/run-schemas.ts | 34 + .../vitnode/src/api/lib/ai/runner.test.ts | 26 + packages/vitnode/src/api/lib/ai/runner.ts | 18 +- .../vitnode/src/api/lib/ai/settings-store.ts | 30 + .../vitnode/src/api/lib/ai/usage-summary.ts | 173 +++++ .../src/api/modules/admin/admin.module.ts | 2 + .../api/modules/admin/ai/ai.admin.module.ts | 50 ++ .../modules/admin/ai/routes/access.route.ts | 321 +++++++++ .../modules/admin/ai/routes/actions.route.ts | 76 +++ .../modules/admin/ai/routes/assist.route.ts | 47 ++ .../modules/admin/ai/routes/history.route.ts | 257 ++++++++ .../modules/admin/ai/routes/models.route.ts | 87 +++ .../modules/admin/ai/routes/overview.route.ts | 136 ++++ .../modules/admin/ai/routes/settings.route.ts | 53 ++ .../admin/ai/routes/sync-pricing.route.ts | 48 ++ .../admin/ai/routes/test-action.route.ts | 53 ++ .../admin/ai/routes/update-action.route.ts | 74 +++ .../admin/ai/routes/update-pricing.route.ts | 109 ++++ .../admin/ai/routes/update-settings.route.ts | 48 ++ .../src/api/modules/admin/ai/schemas.ts | 120 ++++ .../modules/ai/ai-routes.integration.test.ts | 588 +++++++++++++++++ .../vitnode/src/api/modules/ai/ai.module.ts | 15 + .../api/modules/ai/cron/maintenance.cron.ts | 40 ++ .../api/modules/ai/routes/feedback.route.ts | 62 ++ .../api/modules/ai/routes/history.route.ts | 93 +++ .../src/api/modules/ai/routes/run.route.ts | 51 ++ .../src/api/modules/ai/routes/usage.route.ts | 52 ++ .../src/api/modules/middleware/route.ts | 3 + packages/vitnode/src/api/plugin.ts | 3 + .../vitnode/src/lib/ai/format-points.test.ts | 28 + packages/vitnode/src/lib/ai/format-points.ts | 34 + packages/vitnode/src/locales/en.json | 3 + 47 files changed, 4756 insertions(+), 358 deletions(-) create mode 100644 apps/web/content/docs/dev/ai/actions.mdx create mode 100644 apps/web/content/docs/dev/ai/costs-and-limits.mdx create mode 100644 packages/vitnode/src/api/lib/ai/admin-stats.ts create mode 100644 packages/vitnode/src/api/lib/ai/maintenance.ts create mode 100644 packages/vitnode/src/api/lib/ai/run-schemas.ts create mode 100644 packages/vitnode/src/api/lib/ai/settings-store.ts create mode 100644 packages/vitnode/src/api/lib/ai/usage-summary.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/ai.admin.module.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/actions.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/assist.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/history.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/models.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/overview.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/settings.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/sync-pricing.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/test-action.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/update-action.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/update-pricing.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/schemas.ts create mode 100644 packages/vitnode/src/api/modules/ai/ai-routes.integration.test.ts create mode 100644 packages/vitnode/src/api/modules/ai/ai.module.ts create mode 100644 packages/vitnode/src/api/modules/ai/cron/maintenance.cron.ts create mode 100644 packages/vitnode/src/api/modules/ai/routes/feedback.route.ts create mode 100644 packages/vitnode/src/api/modules/ai/routes/history.route.ts create mode 100644 packages/vitnode/src/api/modules/ai/routes/run.route.ts create mode 100644 packages/vitnode/src/api/modules/ai/routes/usage.route.ts create mode 100644 packages/vitnode/src/lib/ai/format-points.test.ts create mode 100644 packages/vitnode/src/lib/ai/format-points.ts diff --git a/apps/web/content/docs/dev/ai/actions.mdx b/apps/web/content/docs/dev/ai/actions.mdx new file mode 100644 index 000000000..83308bddb --- /dev/null +++ b/apps/web/content/docs/dev/ai/actions.mdx @@ -0,0 +1,512 @@ +--- +title: AI Actions +description: Define managed AI features with defineAiAction, register them in a plugin and run them with c.get("ai").run() - with permissions, limits, budgets and history handled by VitNode Core. +icon: Wand +--- + +import { TypeTable } from 'fumadocs-ui/components/type-table' + +An **AI action** is one AI feature a plugin offers - "write an excerpt", "translate a field", "suggest tags". The plugin describes the prompt and the input and output shapes. VitNode Core runs it: it picks the model, checks permissions, enforces limits, holds budget, calls the provider, validates the answer and records what it cost. + +```ts +const { output } = await c.get('ai').run({ + action: EXCERPT_AI_ACTION, + input: { title, content, locale }, +}) +``` + +## Why actions exist + +Calling `generateText` with `c.get("ai").model()` works, but every plugin would then need its own answers to the same questions: who may use this, how much can they spend, what happens when the provider times out, and where did the money go? + +Actions answer those once, in Core: + +- **One place for models.** Admins choose the model, fallback and limits per action in the AdminCP. Plugins never hard-code a model. +- **One place for limits.** Permissions, daily limits, rate limits and concurrency apply to every action the same way. +- **One place for accounting.** Every run and every provider call is recorded with tokens and cost, and charged to the right [budget](/docs/dev/ai/costs-and-limits). + + + Direct SDK calls with `c.get("ai").model()` bypass budgets, limits and + history. Keep them for internal tooling you fully control. + + +## Create an action + +The example adds a "summarize topic" button to a forum plugin's AdminCP. + + + + + +### Define the action + +```ts title="plugins/forum/src/api/ai/actions.ts" +import { defineAiAction } from '@vitnode/core/api/lib/ai/action' +import { aiActionRef } from '@vitnode/core/api/lib/ai/registry' +import { checkStaffPermission } from '@vitnode/core/api/lib/check-staff-permission' +import { z } from 'zod' + +import { CONFIG_PLUGIN } from '@/const' + +export const zodSummarizeTopicSchema = z.object({ + title: z.string().trim().min(1).max(255), + content: z.string().trim().min(1).max(50_000), +}) + +export const summarizeTopicAiAction = defineAiAction({ + id: 'topic.summarize', + description: 'Writes a two-sentence summary of a forum topic.', + promptVersion: 1, + permission: { key: 'summarize', defaultGranted: true }, + requiredCapabilities: ['text'], + defaults: { + maxInputCharacters: 50_000, + maxOutputTokens: 300, + timeoutMs: 30_000, + }, + inputSchema: zodSummarizeTopicSchema, + output: 'text', + outputSchema: z.string().min(1).max(600), + parseText: (text) => text.trim(), + buildPrompt: (input, { instructions }) => ({ + system: ['Summarize the forum topic in two plain sentences.', instructions] + .filter(Boolean) + .join('\n'), + prompt: `${input.title}\n\n${input.content}`, + }), + // The AI permission never replaces access to the content itself. + authorize: async ({ c }) => + await checkStaffPermission(c, { + type: 'admin', + plugin: CONFIG_PLUGIN.pluginId, + module: 'topics', + permission: 'can_view', + }), +}) + +export const forumAiActions = [summarizeTopicAiAction] + +export const SUMMARIZE_TOPIC_AI_ACTION = aiActionRef( + CONFIG_PLUGIN.pluginId, + summarizeTopicAiAction, +) +``` + +`defineAiAction()` validates the definition when the module loads. A bad id, a missing `authorize()` or an out-of-range limit throws an `AiActionDefinitionError` at boot, not in production traffic. + + + + + +### Register it in the plugin + +```ts title="plugins/forum/src/config.api.ts" +import { buildApiPlugin } from '@vitnode/core/api/lib/plugin' + +import { forumAiActions } from '@/api/ai/actions' +import { adminModule } from '@/api/modules/admin/admin.module' +import { CONFIG_PLUGIN } from '@/const' + +export const forumApiPlugin = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + aiActions: forumAiActions, // [!code ++] + modules: [adminModule], + }) +``` + + + + + +### Run it from a route + +```ts title="plugins/forum/src/api/modules/admin/ai/routes/summarize.route.ts" +import { buildRoute } from '@vitnode/core/api/lib/route' +import { z } from 'zod' + +import { + SUMMARIZE_TOPIC_AI_ACTION, + zodSummarizeTopicSchema, +} from '@/api/ai/actions' +import { CONFIG_PLUGIN } from '@/const' + +export const summarizeTopicRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: 'topics', permission: 'can_view' }, + route: { + method: 'post', + description: 'Summarize a forum topic with the configured AI model.', + path: '/summarize', + request: { + body: { + required: true, + content: { 'application/json': { schema: zodSummarizeTopicSchema } }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { schema: z.object({ text: z.string() }) }, + }, + description: 'The summary', + }, + 403: { description: 'No access to topics or to this AI feature' }, + 429: { description: 'A personal AI limit was reached' }, + 502: { description: 'The AI provider failed or answered unusably' }, + 503: { description: 'AI is switched off or the site budget is used up' }, + }, + }, + handler: async (c) => { + // [!code ++:4] + const { output } = await c.get('ai').run({ + action: SUMMARIZE_TOPIC_AI_ACTION, + input: c.req.valid('json'), + }) + + return c.json({ text: output }, 200) + }, +}) +``` + +`output` is typed as `string` - inferred from the action's `outputSchema`. When the run fails, `run()` throws an `AiError` and the route answers with its [error code](#error-codes). + + + + + +The `@vitnode/blog` plugin is a complete real example: `plugins/blog/src/api/ai/actions.ts` and its `admin/ai` routes. + +## Action ids + +An action has a **local id**, unique inside its plugin: lowercase, dot-separated words such as `topic.summarize`. + +Core stores it under a **canonical key**, `:`: + +```text +@acme/forum:topic.summarize +@vitnode/blog:excerpt.generate +``` + +Two plugins may both have an `excerpt.generate`; their canonical keys differ. The same canonical key twice throws at boot. Settings, history and limits all use the canonical key. + +`aiActionRef(pluginId, action)` builds the canonical key **and** remembers the action's input and output types. Pass it to `run()` and TypeScript checks `input` and infers `output`. A plain string key also works, but its output is `unknown`. + +## Prompt versions and instructions + +`promptVersion` is stored with every run. Bump it whenever the prompt changes meaningfully, so the history shows which prompt produced which result. + +`buildPrompt(input, { instructions })` returns either `{ prompt, system? }` or `{ messages, system? }`. `instructions` is optional extra guidance an admin set for this action in the AdminCP, or `null` - append it to the system prompt. + +## Input and output + +- **`inputSchema`** (Zod) validates the input before anything else happens. Invalid input fails with `AI_INVALID_INPUT`. +- **`measureInput(input)`** returns the number of characters counted against `maxInputCharacters`. By default every string in the input counts. Override it when only part of the input reaches the prompt - the blog excerpt counts only the text it extracts from the HTML. +- **`output: "text"`** - the model returns text, `parseText(text, input)` turns it into your output, then `outputSchema` checks it. Throw from `parseText()` to reject an answer. +- **`output: "object"`** - the model returns structured JSON validated by `outputSchema`. The action must require the `structured-output` capability, and it cannot be streamed. + +An empty answer, a `parseText()` that throws or a schema mismatch fails with `AI_INVALID_OUTPUT`. The user is not charged for it. + +```ts title="An object action" +export const suggestTagsAiAction = defineAiAction({ + id: 'topic.tags', + promptVersion: 1, + permission: 'tags', + actors: ['system'], + requiredCapabilities: ['text', 'structured-output'], + defaults: { + maxInputCharacters: 20_000, + maxOutputTokens: 200, + timeoutMs: 30_000, + maxRetries: 1, + }, + inputSchema: z.object({ content: z.string().min(1) }), + output: 'object', + outputSchema: z.object({ tags: z.array(z.string().max(32)).max(5) }), + buildPrompt: (input) => ({ + system: 'Suggest up to five short, lowercase tags for this forum topic.', + prompt: input.content, + }), +}) + +export const TOPIC_TAGS_AI_ACTION = aiActionRef( + CONFIG_PLUGIN.pluginId, + suggestTagsAiAction, +) +``` + +`actors: ["system"]` makes this a background-only action, so it needs no `authorize()` - see [Actors](#actors-users-and-the-system). + +### Images + +For a vision action, require `image-input` and pass images as message parts: + +```ts +buildPrompt: (input) => ({ + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe this image in one sentence.' }, + { type: 'image', image: new URL(input.imageUrl) }, + ], + }, + ], +}), +``` + +`maxImages` defaults to `1` for these actions and bounds how many images one run may send. Only models that declare `image-input` are used - see [Model capabilities](/docs/dev/ai#model-capabilities). + +## Permissions and authorize() + +Every action names a **permission**. Admins grant it per role in the AdminCP. Several actions may share one key - they then share one switch and one daily counter. + +```ts +permission: 'summarize' // same as { key: 'summarize', defaultGranted: false } +permission: { key: 'summarize', defaultGranted: true } +``` + +`defaultGranted` applies while no role has a setting for the key. The canonical permission is `:`. Two actions sharing a key must agree on `defaultGranted`, or boot fails. + +`authorize()` is **required** for actions users can run. The AI permission says "this role may use AI summaries". `authorize()` says "this user may read _this_ topic". Both must pass: + +```ts +// canReadTopic() is the forum plugin's own access check +authorize: async ({ c, input, userId }) => + await canReadTopic(c, { topicId: input.topicId, userId }), +``` + +`resource` is the optional `{ type, id }` the caller passed to `run()`. It is also stored with the run, so history can link back to the content. + +## Actors: users and the system + +`actors` decides who may run an action. The server decides the actor - never the request. + +| Actor | Runs with | Charged to | +| :------- | :-------------------------- | :--------------------------------------------- | +| `user` | `c.get("ai").run()` | The site budget and the user's AI points | +| `system` | `c.get("ai").runAsSystem()` | The site budget and the optional system budget | + +The default is `["user"]`. `run()` uses the signed-in AdminCP user first, then the site user. Without one, it fails with `AI_UNAUTHORIZED`. + +Use `runAsSystem()` only from trusted server code - cron jobs, queue tasks, event listeners. No route should pass a client's request into it. + +```ts title="plugins/forum/src/api/cron/tag-topics.cron.ts" +import { isAiError } from '@vitnode/core/api/lib/ai/errors' +import { buildCron } from '@vitnode/core/api/lib/cron' + +import { TOPIC_TAGS_AI_ACTION } from '@/api/ai/actions' + +export const tagTopicsCron = buildCron({ + name: 'tag-new-topics', + description: 'Suggest tags for new forum topics', + schedule: '*/15 * * * *', + handler: async (c) => { + for (const topic of await loadUntaggedTopics(c)) { + try { + const { output } = await c.get('ai').runAsSystem({ + action: TOPIC_TAGS_AI_ACTION, + input: { content: topic.content }, + resource: { type: 'forum_topic', id: topic.id }, + }) + await saveTopicTags(c, topic.id, output.tags) + } catch (error) { + // Out of budget: stop now and try again on the next tick. + if (isAiError(error) && error.code === 'AI_BUDGET_EXHAUSTED') return + throw error + } + } + }, +}) +``` + +## Defaults and limits + +`defaults` sets an action's limits. Admins can override most of them per action in the AdminCP. Values outside the allowed range throw at boot. + +| Field | Required | Default | Range | +| :------------------- | :------- | :------------------------------- | :-------------- | +| `maxInputCharacters` | Yes | - | 1 – 1,000,000 | +| `maxOutputTokens` | Yes | - | 1 – 200,000 | +| `timeoutMs` | Yes | - | 1,000 – 600,000 | +| `maxRetries` | No | `0` | 0 – 3 | +| `maxSteps` | No | `1` | 1 – 10 | +| `maxImages` | No | `1` with `image-input`, else `0` | 0 – 10 | +| `dailyLimit` | No | `null` (no limit) | 0 – 100,000 | + +- `timeoutMs` applies to each attempt. +- `maxRetries` is extra attempts on the same model after a failed provider call. +- `maxSteps` is model round trips inside one attempt (tool loops). Each step is billed as its own call. +- `dailyLimit` counts runs per user per day for the action's permission key. A role's daily limit takes precedence over the admin's action setting, which takes precedence over this default. +- `maxImages` cannot be changed by admins - it bounds the budget reserved for images. + +Admins can also, per action: switch it off, assign a model, assign a fallback model, and add `instructions`. + +## Run options + + + +`run()` and `runAsSystem()` resolve to: + +```ts +{ + output: Output, // validated by outputSchema + runId: number, + usage: { + chargedPoints: string, // points charged to the user, full precision; "0" for system runs + costKnown: boolean, + modelId: string, // the configured model that answered + }, +} +``` + +It is safe to return `usage` to the browser. It never contains prices or provider details. + +## What happens during a run + +1. Find the action, check the actor, validate `input`. +2. Check the global AI switch and the action's switch. +3. For users: check the role permission, then `authorize()`. +4. Measure the input and the number of images. +5. Pick the model: the admin's choice, or the first configured model with every required capability. Resolve the optional fallback - an incompatible fallback is skipped. +6. **Reserve** budget for the worst case and check rate and concurrency limits, in one short database transaction. +7. Call the provider - outside any transaction. Retry up to `maxRetries` times, then try the fallback model once. +8. Validate the output. +9. **Settle**: charge the real cost and release the rest of the reservation. +10. Emit `ai.run.completed` or `ai.run.failed`. + +A canceled run stops immediately. An invalid answer is not retried on the same model, but the fallback model still gets one try. Everything before step 6 costs nothing. [Costs, Points & Budgets](/docs/dev/ai/costs-and-limits) explains steps 6 and 9. + +## Stream a text action + +`c.get("ai").stream()` streams a `text` action for the signed-in user. Object actions cannot be streamed. + +```ts +handler: async (c) => { + const { textStream } = await c.get('ai').stream({ + action: SUMMARIZE_TOPIC_AI_ACTION, + input: c.req.valid('json'), + signal: c.req.raw.signal, // stop paying when the reader leaves + }) + + return new Response(textStream.pipeThrough(new TextEncoderStream()), { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }) +} +``` + +The stream also returns `result`, a promise that resolves to the validated output once the stream ends, and rejects with an `AiError` if the full text is invalid. A stream makes one attempt on the primary model - no retries, no fallback. If the reader cancels, the run settles as `canceled` and the user is not charged. + +## Error codes + +Every refusal and failure is an `AiError` with a stable `code`. Routes answer with this JSON body - the browser should key its messages on `code`, never on the English `message`: + +```json +{ + "code": "AI_USER_LIMIT_REACHED", + "message": "You used all your AI points for this period.", + "resetsAt": "2026-11-01T00:00:00.000Z", + "runId": 42 +} +``` + +`resetsAt` is set for limit and budget errors. `runId` is set when a run was recorded. + +| Code | Status | When | +| :----------------------- | :----- | :---------------------------------------------------------------------- | +| `AI_NOT_CONFIGURED` | 400 | No `ai.models` are configured. | +| `AI_INVALID_INPUT` | 400 | The input failed `inputSchema`, or an object action was streamed. | +| `AI_INPUT_TOO_LARGE` | 413 | The input exceeds `maxInputCharacters` or `maxImages`. | +| `AI_DISABLED` | 503 | An admin switched AI off for the whole site. | +| `AI_ACTION_DISABLED` | 403 | An admin switched this action off. | +| `AI_ACTION_UNKNOWN` | 404 | No installed plugin registers this key. | +| `AI_UNAUTHORIZED` | 403 | Not signed in, no AI permission, `authorize()` refused, or wrong actor. | +| `AI_MODEL_INCOMPATIBLE` | 409 | No model has the required capabilities. | +| `AI_PRICING_MISSING` | 409 | The model has no pricing while a budget cap applies. | +| `AI_USER_LIMIT_REACHED` | 429 | The user's monthly AI points are used up. | +| `AI_DAILY_LIMIT_REACHED` | 429 | The user reached today's limit for this permission. | +| `AI_RATE_LIMITED` | 429 | Too many runs started in the last minute. | +| `AI_CONCURRENCY_LIMITED` | 429 | Too many runs in flight. | +| `AI_BUDGET_EXHAUSTED` | 503 | The site budget (or the system budget) for this month is used up. | +| `AI_DUPLICATE_REQUEST` | 409 | The `idempotencyKey` was already used. | +| `AI_PROVIDER_FAILED` | 502 | The provider returned an error. | +| `AI_INVALID_OUTPUT` | 502 | The answer was empty or failed `parseText()` / `outputSchema`. | +| `AI_TIMEOUT` | 504 | The provider took longer than `timeoutMs`. | +| `AI_CANCELED` | 408 | The `signal` aborted the run. | + +Import the helpers from `@vitnode/core/api/lib/ai/errors`: `AiError`, `isAiError()`, `AI_ERROR_CODES` and `AI_ERROR_STATUS`. + +## Events + +Each run that got past the reservation emits one event after it settles: + +| Event | Payload | +| :----------------- | :------------------------------------------------------- | +| `ai.run.completed` | `actionKey`, `actorType`, `runId`, `userId` | +| `ai.run.failed` | `actionKey`, `actorType`, `errorCode`, `runId`, `userId` | + +`userId` is `null` for system runs. Refusals before the reservation - no permission, limit reached - emit nothing. A listener that throws never breaks the run. + +```ts title="plugins/forum/src/api/lib/ai-events.ts" +import { buildEventListener } from '@vitnode/core/api/lib/events' + +export const aiFailureListener = buildEventListener({ + event: 'ai.run.failed', + name: 'log-ai-failures', + handler: async (c, payload) => { + if (!payload.actionKey.startsWith('@acme/forum:')) return + await c + .get('log') + .warn(`AI run ${payload.runId} failed: ${payload.errorCode}`) + }, +}) +``` + +See [Events](/docs/dev/events) for registering listeners. + +## Learn more + + + + + diff --git a/apps/web/content/docs/dev/ai/costs-and-limits.mdx b/apps/web/content/docs/dev/ai/costs-and-limits.mdx new file mode 100644 index 000000000..c62f41a6e --- /dev/null +++ b/apps/web/content/docs/dev/ai/costs-and-limits.mdx @@ -0,0 +1,331 @@ +--- +title: Costs, Points & Budgets +description: How VitNode prices every AI call, converts it to AI points, reserves budget before a provider call and charges users only for results they receive. +icon: Coins +--- + +import { TypeTable } from 'fumadocs-ui/components/type-table' + +Every [AI action](/docs/dev/ai/actions) run is priced in **USD**, charged to the site's budget, and - for users - charged in **AI points**. VitNode reserves the worst case before calling a provider, then settles the real cost afterwards. Users pay only for results they actually receive. + +```text +1,000 input tokens × $3 / 1M = $0.003 + 200 output tokens × $15 / 1M = $0.003 + total = $0.006 → 6 AI points +``` + +## Usage and cost of a call + +Each provider call is stored with its token usage and its cost: + +```ts +interface AiUsage { + inputTokens: number | null + outputTokens: number | null + cacheReadTokens: number | null + cacheWriteTokens: number | null + reasoningTokens: number | null // already part of outputTokens +} + +type AiCost = + | { source: 'provider'; amountUsd: string } + | { source: 'pricing' | 'manual'; amountUsd: string; pricingVersion: string } + | { source: 'unknown'; amountUsd: null; reason: string } +``` + +Amounts are decimal strings with 12 decimal places, stored as `numeric(24, 12)` in PostgreSQL. Money never passes through JavaScript floats, so a thousand tiny calls add up exactly. + +### Unknown is not zero + +`null` means "nobody told us". `0` means "we know it was zero". VitNode keeps them apart everywhere: + +- A provider that does not report token usage gives `null` tokens and an `unknown` cost. +- A model without pricing gives an `unknown` cost, with the reason `"no pricing"`. +- An `unknown` cost is never shown or summed as `$0`. When it must be charged, it is charged as the **full reservation** - the most the call could have cost. + +## How cost is resolved + +For every call, VitNode takes the first of these that is available: + +1. **Provider-reported cost** - what the provider says it billed (AI Gateway, OpenRouter). +2. **Pricing** - token usage × the model's effective price: + 1. an admin's manual price from the AdminCP - it **replaces** the catalog price, it is never ignored; + 2. the synced AI Gateway catalog price (gateway models only); + 3. the `pricing` from `vitnode.api.config.ts`. +3. **Unknown** - never zero. + +Each estimated call stores the pricing version it used. Changing a price later never rewrites history. + +## Pricing + +Prices are USD **per one million tokens**, as decimal strings: + +```ts title="apps/api/src/vitnode.api.config.ts" +pricing: { + rates: { + inputPerMillion: '3', + outputPerMillion: '15', + cacheReadPerMillion: '0.30', + cacheWritePerMillion: '3.75', + }, + // [!code ++:6] + tiers: [ + { + aboveInputTokens: 200_000, + rates: { inputPerMillion: '6', outputPerMillion: '22.5' }, + }, + ], +} +``` + + + +How the rates apply: + +- **Cache tokens** are priced separately from uncached input. If a call reports cache tokens but the pricing has no cache rate, the cost becomes `unknown` - never quietly cheaper. +- **Tiers** price long-context calls. When a call's input tokens exceed `aboveInputTokens`, the **whole call** uses that tier's rates. A tier's `rates` replace the base rates entirely, so repeat the cache prices inside the tier if the provider charges them. +- **Reasoning tokens** are already part of `outputTokens`. They are stored for display, never billed twice. +- **Flat units** (`perRequest`, `perImage`) are added on top of the token cost. + +A worked example with the pricing above - 10,000 input tokens (2,000 cache reads, 1,000 cache writes) and 500 output tokens: + +```text +7,000 uncached × $3 / 1M = $0.021 +2,000 cache read × $0.30 / 1M = $0.0006 +1,000 cache write × $3.75 / 1M = $0.00375 + 500 output × $15 / 1M = $0.0075 + total = $0.03285 → 32.85 AI points +``` + +### Gateway vs direct pricing + +`pricing` is the price **on this connection**. The AI Gateway and the direct provider are different connections, and they can bill differently. Set the price you actually pay. + +| Connection | Reports its cost? | What VitNode needs | +| :------------------------------------------------ | :-------------------------- | :------------------------------------------------------ | +| AI Gateway id, e.g. `'anthropic/claude-sonnet-5'` | Often, in response metadata | Pricing for reservations - config or the synced catalog | +| OpenRouter-compatible provider | Yes, `usage.cost` | Pricing for reservations | +| Direct provider, e.g. `openai('gpt-4o-mini')` | No | `pricing` in config, or a manual price in the AdminCP | + +The gateway catalog sync copies the AI Gateway's published prices onto **gateway models only**. A direct connection is never priced from the gateway catalog. A changed catalog price becomes a new version; the old one stays for history. + + + The reservation happens _before_ the call, so it can only use pricing. A + gateway model that reports its cost but has no pricing at all is still refused + with `AI_PRICING_MISSING` while a budget cap applies. + + +### Provider-reported cost + +Provider adapters read cost and request ids from a provider's response metadata. The AI Gateway and OpenRouter adapters are built in. Every other provider reports no cost, so pricing estimates it. + +Add an adapter for another provider that reports its cost: + +```ts title="apps/api/src/vitnode.api.config.ts" +import type { AiProviderAdapter } from '@vitnode/core/api/lib/ai/usage-cost' + +import { decimalFromNumber } from '@vitnode/core/api/lib/ai/decimal' + +const acmeAdapter: AiProviderAdapter = { + id: 'acme', + matches: (providerId) => providerId.startsWith('acme'), + reportedCost: (metadata) => { + const cost = metadata?.acme?.costUsd + + return typeof cost === 'number' ? decimalFromNumber(cost) : null + }, + requestId: ({ responseId }) => responseId ?? null, +} + +export const vitNodeApiConfig = buildApiConfig({ + ai: { + models: [/* ... */], + providerAdapters: [acmeAdapter], // [!code ++] + }, +}) +``` + +`matches()` receives the model's provider id - `"gateway"` for AI Gateway string ids, otherwise the SDK model's `provider`. Your adapters are checked before the built-in ones. An optional `lookupCost(requestId)` lets reconciliation ask for the billed amount later. + +## Runs with several calls + +One run can make several provider calls: a retry, a fallback model, or extra steps in a tool loop. Each one is stored as its own call with its own usage and cost. + +The run's cost is the sum of its calls, computed once at settlement. It is **known** only when every call's cost is known. If any call is unknown, the run is unknown and is charged as described in [Who pays what](#who-pays-what). + +## Reconciliation and adjustments + +Some providers report the billed amount only after the response - the AI Gateway can look a generation up by its id. Reconciliation revisits recent calls whose cost was estimated or unknown, asks the provider adapter's `lookupCost()`, and replaces the cost with the billed amount. + +Every correction: + +- writes an **audit row** with the previous cost, the new cost and the difference; +- happens **once** per call - a second attempt changes nothing; +- moves the budgets by the **difference** only, so nothing is counted twice; +- adjusts the user's points only if the user was charged for that run. + +A provider-reported cost is final and is never reconciled. + +## AI points + +Users are charged in **AI points**, not dollars: + +> **1 AI point = 0.001 USD** of API cost (conversion version 1) + +| API cost | AI points | +| :------- | :-------- | +| $0.0003 | 0.3 | +| $0.003 | 3 | +| $0.012 | 12 | +| $1 | 1,000 | + +- Points are an internal usage unit. Nobody buys them. +- Points keep full precision when stored and charged. The UI shows at most one decimal, and `<0.1` for tiny positive amounts. +- The conversion version is stored on every run. A future rate change becomes a new version - old runs keep theirs. + +## Budgets + +Every run holds room in each budget that applies to it: + +| Budget | Unit | Applies to | Limit set by | On overflow | +| :------------ | :----- | :---------- | :------------------------------------ | :----------------------- | +| Site (global) | USD | Every run | Monthly site budget (`null` = no cap) | `AI_BUDGET_EXHAUSTED` | +| System | USD | System runs | Optional system budget (`null` = off) | `AI_BUDGET_EXHAUSTED` | +| User | Points | User runs | Role policies and user overrides | `AI_USER_LIMIT_REACHED` | +| Daily | Runs | User runs | Daily limit per permission | `AI_DAILY_LIMIT_REACHED` | + +The system budget is an extra cap on background work, such as automatic image ALT text. System runs never touch a user's points or daily counters, but they still count against the site budget. + +Budgets are calendar periods in the **site's time zone** (the default language's time zone). A monthly budget resets at local midnight on the 1st, wherever the server runs. Limit errors include `resetsAt`. + +## Reservations + +Before any provider call, VitNode **reserves** the most the run could possibly cost: + +- every attempt (`1 + maxRetries`), every step (`maxSteps`) and the fallback model; +- each at the model's dearest input rate and the highest tier the input could reach; +- the full `maxOutputTokens` for every call; +- the prompt measured in UTF-8 bytes - always at least its token count - plus `imageInputTokens` per image. + +The reservation is a conservative upper bound. Settlement charges the real cost and releases the rest. + +How it stays correct with many API instances: + +- Reserving is **one short database transaction**. It locks the budget rows, checks the rate, concurrency and every cap, then writes the run and its holds. +- Budget rows are always locked in the **same order** (by scope key), so two runs can never deadlock. +- **No transaction is open during the provider call.** A slow model never holds a database lock. +- Settlement is **idempotent**. A run settles exactly once - a second settlement changes nothing. + +## Who pays what + +| Outcome | Site budget (USD) | User points and daily count | +| :----------------------------------------- | :--------------------------- | :--------------------------------- | +| Valid result delivered, cost known | Real cost | Real cost in points, +1 run | +| Valid result delivered, cost unknown | Full reservation | Full reservation in points, +1 run | +| Retry: a lost attempt, then a valid answer | Full reservation (unknown) | Only the answering call, +1 run | +| Provider returned an error | Real cost (usually $0) | Nothing | +| Invalid output (empty, failed validation) | Real cost - tokens were used | Nothing | +| Timeout or canceled | Full reservation (unknown) | Nothing | +| Process died mid-call (lease expired) | Full reservation (unknown) | Nothing | + +**A user is charged only when a valid result is delivered.** When the provider fails, times out, or the answer is unusable, the site pays for whatever the provider may have billed. Reconciliation can correct an unknown charge later. + +Runs whose process died stay `running` until their lease expires - `timeoutMs` per possible attempt plus one minute. The `ai-maintenance` cron (every 10 minutes) then settles them as `uncertain`. The same cron reconciles costs and prunes history, so the host must run VitNode's cron - see [Cron](/docs/dev/cron). + +## Role policies and user overrides + +Admins control who may use AI and how much in the AdminCP: + +- **AI permissions per role** - grant or deny each action permission, with an optional daily limit. +- **Monthly point policies per role** - an allowance in points, or unlimited. +- **Default monthly points** - the site-wide allowance when no role policy gives more. +- **User overrides** - block a user, make them unlimited, or set their own allowance. + +How multiple roles combine: + +- **Any granting role grants.** A permission no role mentions falls back to the action's `defaultGranted`. +- **The largest allowance wins - never summed.** A user in "Editors" (500 points) and "Moderators" (2,000 points) gets 2,000, not 2,500. The default monthly points act as a floor. +- **The largest daily limit wins** among granting roles. A granting role with no daily limit lifts the role limit, and the action's own daily limit applies. +- **Root roles** always have access, with no personal point cap. +- **A user override replaces** what the roles give. A blocked user cannot run any action. + + + The default monthly points are `0`. Until an admin sets a role policy, an + override or a higher default, only root roles can run priced user actions - + everyone else gets `AI_USER_LIMIT_REACHED`. + + +## Rate and concurrency limits + +| Setting | Default | Counts | +| :------------------ | :------ | :----------------------------------------- | +| Requests per minute | 10 | Runs a user started in the last 60 seconds | +| User concurrency | 2 | A user's runs still in flight | +| System concurrency | 2 | System runs still in flight | + +Both are checked inside the reservation transaction, under the same locks as the budgets. Two requests from one user cannot both slip past the limit. A refused run fails with `AI_RATE_LIMITED` or `AI_CONCURRENCY_LIMITED` before any provider call. + +## Missing pricing + +When a model has no effective pricing, VitNode cannot bound the run: + +- **Under any money cap** - the site budget, the system budget or the user's points - the run is refused with `AI_PRICING_MISSING` before any provider call. +- **Daily run counts** need no pricing. +- **With no cap at all**, the run goes ahead. Its cost is recorded as `unknown` and counted among unknown costs in the AdminCP. + +Add `pricing` to the model in `vitnode.api.config.ts`, or a manual price in the AdminCP, to fix it. + +## History and retention + +Every run and every provider call is stored in PostgreSQL: action, actor, model, prompt version, tokens, cost source, charges, status and error code. + +History is kept for **365 days** by default (adjustable from 7 to 3,650). Pruning runs on its own schedule, independent of queue cleanup, and removes only settled runs and finished budget periods with nothing reserved. + +## No Redis required + +Budgets, reservations, rate limits, concurrency and idempotency keys live in PostgreSQL and change inside transactions. AI accounting is correct with any number of API instances, with or without `REDIS_URL`. + +## Learn more + + + + + diff --git a/apps/web/content/docs/dev/ai/index.mdx b/apps/web/content/docs/dev/ai/index.mdx index b9ff029d2..b965426bd 100644 --- a/apps/web/content/docs/dev/ai/index.mdx +++ b/apps/web/content/docs/dev/ai/index.mdx @@ -1,16 +1,16 @@ --- title: AI Setup -description: Configure Vercel AI SDK providers and models in VitNode API config to use with c.get("ai"). +description: Configure Vercel AI SDK models in the VitNode API config, declare what each model can do and what it costs, then use them with c.get("ai"). icon: Sparkles --- -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { TypeTable } from 'fumadocs-ui/components/type-table' -VitNode integrates the [Vercel AI SDK](https://ai-sdk.dev). Register models in `vitnode.api.config.ts` once, then resolve them inside any API handler via `c.get("ai")`. +VitNode integrates the [Vercel AI SDK](https://ai-sdk.dev). Register models in `vitnode.api.config.ts` once, then use them inside any API handler via `c.get("ai")`. ## Quick start -### 1. Configure Models in API Config +### 1. Configure models in the API config You can configure models via string identifiers (using AI Gateway) or direct provider instances: @@ -19,13 +19,17 @@ import { openai } from '@ai-sdk/openai' import { buildApiConfig } from '@vitnode/core/vitnode.config' export const vitNodeApiConfig = buildApiConfig({ - // [!code ++:10] + // [!code ++:13] ai: { models: [ { id: 'default', name: 'GPT-4o Mini', model: openai('gpt-4o-mini'), + capabilities: ['text', 'structured-output', 'streaming'], + pricing: { + rates: { inputPerMillion: '0.15', outputPerMillion: '0.60' }, + }, }, ], }, @@ -34,38 +38,140 @@ export const vitNodeApiConfig = buildApiConfig({ Set `OPENAI_API_KEY` in your `.env` file. +`capabilities` and `pricing` are optional, but [AI actions](/docs/dev/ai/actions) rely on them: capabilities decide which model can run an action, and pricing turns token usage into money. See [Model capabilities](#model-capabilities) and [Costs, Points & Budgets](/docs/dev/ai/costs-and-limits). + --- -### 2. Use in Route Handlers +### 2. Use it in a route handler -```ts -import { generateText } from 'ai' +For user-facing features, run a registered [AI action](/docs/dev/ai/actions). It checks permissions, limits and budgets, and records the cost: +```ts handler: async (c) => { // [!code ++:4] - const { text } = await generateText({ - model: c.get('ai').model(), // Resolves the configured "default" model - prompt: 'Write a summary of this discussion.', + const { output } = await c.get('ai').run({ + action: EXCERPT_AI_ACTION, + input: c.req.valid('json'), }) - return c.json({ text }) + return c.json({ text: output }) +} +``` + +For internal tooling you can also resolve the raw model and call the SDK directly - see [AI Usage](/docs/dev/ai/usage). + +--- + +## Model capabilities + +A capability says what a configured language model can do. VitNode never guesses it from the model's name. A model without `capabilities` is treated as `["text"]`. + +| Capability | Meaning | +| :------------------ | :----------------------------------------------- | +| `text` | Text in, text out. | +| `image-input` | Accepts images in the prompt (vision). | +| `structured-output` | Returns reliable JSON objects (`Output.object`). | +| `streaming` | Streams tokens with `streamText`. | + +Every [AI action](/docs/dev/ai/actions) declares the capabilities it needs. When a run starts, VitNode picks the first configured model that has all of them. If no model fits, the run fails with `AI_MODEL_INCOMPATIBLE` before anything is sent to a provider. + + + `image-input` means the model can _read_ images, which features such as image + ALT text need. Generating images is a different thing: register those models + under `imageModels` and resolve them with `c.get("ai").imageModel()`. Add + `image-input` only to models whose provider docs say they accept images - + otherwise VitNode keeps images away from them. + + +--- + +## Model pricing + +`pricing` is what the model costs **on this connection**, in USD per one million tokens. Prices are decimal strings, so money never goes through floating point: + +```ts title="apps/api/src/vitnode.api.config.ts" +{ + id: 'default', + name: 'Claude Sonnet', + model: anthropic('claude-sonnet-4-5'), + capabilities: ['text', 'image-input', 'structured-output', 'streaming'], + // [!code ++:8] + pricing: { + rates: { + inputPerMillion: '3', + outputPerMillion: '15', + cacheReadPerMillion: '0.30', + cacheWritePerMillion: '3.75', + }, + }, } ``` +Without pricing, a call's cost stays **unknown** - never zero. Under a budget cap, a model with no pricing is refused with `AI_PRICING_MISSING`. Tiers, flat fees, gateway vs direct pricing and admin overrides are covered in [Costs, Points & Budgets](/docs/dev/ai/costs-and-limits#pricing). + +### Model options + + + +`ai` also accepts `providerAdapters` - readers for providers that report their own cost. The AI Gateway and OpenRouter readers are built in. See [Provider-reported cost](/docs/dev/ai/costs-and-limits#provider-reported-cost). + --- -## Multiple Models & Embeddings +## Multiple models & embeddings Configure dedicated models for fast completions, reasoning, and vector embeddings: ```ts title="apps/api/src/vitnode.api.config.ts" ai: { models: [ - { id: "default", name: "GPT-4o Mini", model: openai("gpt-4o-mini") }, - { id: "reasoning", name: "Claude 3.5 Sonnet", model: anthropic("claude-3-5-sonnet-20241022") }, + { + id: 'default', + name: 'GPT-4o Mini', + model: openai('gpt-4o-mini'), + capabilities: ['text', 'structured-output', 'streaming'], + }, + { + id: 'reasoning', + name: 'Claude Sonnet', + model: 'anthropic/claude-sonnet-5', // AI Gateway id + capabilities: ['text', 'image-input', 'structured-output', 'streaming'], + }, ], embeddingModels: [ - { id: "default", name: "Text Embedding 3", model: openai.embedding("text-embedding-3-small") }, + { id: 'default', name: 'Text Embedding 3', model: openai.embedding('text-embedding-3-small') }, ], } ``` @@ -75,15 +181,26 @@ Resolve specific models by ID: ```ts const smartModel = c.get('ai').model('reasoning') const embedModel = c.get('ai').embeddingModel() +const capabilities = c.get('ai').capabilities('reasoning') // ['text', 'image-input', ...] ``` +`model()`, `embeddingModel()`, `imageModel()` and `models()` work as before. Admins can assign a different model (and a fallback) to each action in the AdminCP; an assigned model that lacks a required capability is refused. + --- -## Reading the Model List in the Browser +## Reading the model list in the browser + +Provider instances, API keys and prices stay on the server. `c.get("ai").models()` returns only the public half of each entry: -Provider instances and API keys stay on the server. What the browser gets is the -public half of each entry - `id`, `name`, and the provider's model id string - -published on the middleware route and read with +| Field | Value | +| :------------- | :-------------------------------------------------------------------- | +| `id` | The configured id, e.g. `"default"`. | +| `name` | The configured label. | +| `model` | The provider's model id string, e.g. `"gpt-4o-mini"`. | +| `provider` | `"gateway"` for AI Gateway ids, otherwise the SDK model's `provider`. | +| `capabilities` | The declared capabilities, or `["text"]`. | + +The middleware route publishes this list, and the browser reads it with [`useMiddlewareConfigQuery()`](/docs/dev/fetcher#deployment-configuration): ```tsx title="plugins/writer/src/pages/model-picker.tsx" @@ -111,9 +228,19 @@ The first entry is the default - the one `c.get("ai").model()` resolves when a handler is given no id. The list is empty when no `ai.models` are configured, so a picker can hide itself rather than offering nothing. -## Learn More +## Learn more + + + Calling `generateText` with `c.get("ai").model()` bypasses permissions, + budgets, AI points and run history - nobody pays attention to the bill until + the bill arrives. For user-facing features, define an [AI + action](/docs/dev/ai/actions) and run it with `c.get("ai").run()`. Keep direct + SDK calls for internal tooling you fully control. + + { - const { text } = c.req.valid("json") + const { text } = c.req.valid('json') // [!code ++:6] const { text: summary } = await generateText({ - model: c.get("ai").model(), - system: "You are a concise summary assistant.", + model: c.get('ai').model(), + system: 'You are a concise summary assistant.', prompt: `Summarize the following content: ${text}`, @@ -75,12 +86,12 @@ ${text}`, Stream LLM responses directly to the client: ```ts -import { streamText } from "ai" +import { streamText } from 'ai' handler: async (c) => { const result = streamText({ - model: c.get("ai").model(), - prompt: "Write an introduction to WebSockets.", + model: c.get('ai').model(), + prompt: 'Write an introduction to WebSockets.', }) // [!code ++:1] @@ -95,8 +106,8 @@ handler: async (c) => { Extract strongly typed JSON from model completions using Zod: ```ts -import { generateObject } from "ai" -import { z } from "zod" +import { generateObject } from 'ai' +import { z } from 'zod' const postMetadataSchema = z.object({ title: z.string(), @@ -106,9 +117,9 @@ const postMetadataSchema = z.object({ // [!code ++:6] const { object } = await generateObject({ - model: c.get("ai").model(), + model: c.get('ai').model(), schema: postMetadataSchema, - prompt: "Generate SEO metadata for an article on Postgres indexing.", + prompt: 'Generate SEO metadata for an article on Postgres indexing.', }) ``` @@ -119,12 +130,12 @@ const { object } = await generateObject({ Calculate vector embeddings for semantic search: ```ts -import { embed } from "ai" +import { embed } from 'ai' // [!code ++:4] const { embedding } = await embed({ - model: c.get("ai").embeddingModel(), - value: "How to configure Redis caching in VitNode", + model: c.get('ai').embeddingModel(), + value: 'How to configure Redis caching in VitNode', }) ``` diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index 632697e58..52df73d61 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -510,6 +510,24 @@ own preferences are saved, by them or by staff on their AdminCP user page. pauses, deletes or resets everything, or note when staff changed how a single member is notified. +## AI events + +Declared in `VitNodeEvents` in `packages/vitnode/src/api/models/events.ts` and +emitted by the shared AI runner (see [AI Actions](/docs/dev/ai/actions)). They +fire **after** the run was settled, so its cost and points are already in the +history when a listener reads it. A failing listener never breaks the run. + +| Event | Payload | Fires when | +| ------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------ | +| `ai.run.completed` | `{ actionKey, actorType, runId, userId }` | A managed AI run delivered a valid result | +| `ai.run.failed` | `{ actionKey, actorType, errorCode, runId, userId }` | A run ended without a valid result: provider failure, cancel, bad output | + +`userId` is `null` for system work such as automatic ALT text. Prompts and +outputs are never part of the payload. + +**A listener would** post a notice when an editor's AI suggestion fails, or +count AI usage per plugin in your own analytics. + ## Page layout events One name, declared in `VitNodeEvents` in diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index de84fa86f..f9e031172 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -24,6 +24,9 @@ "@vitnode/core:notifications:can_edit": "Edycja ustawień powiadomień", "@vitnode/core:notifications:can_manage": "Testowe e-maile, czyszczenie i strefa zagrożenia: wstrzymanie, anulowanie e-maili, oznaczanie jako przeczytane, usuwanie i reset preferencji", "@vitnode/core:notifications:can_view": "Podgląd ustawień i stanu dostarczania powiadomień", + "@vitnode/core:ai": "AI", + "@vitnode/core:ai:can_view": "Wyświetlanie użycia, ustawień i historii AI", + "@vitnode/core:ai:can_manage": "Zarządzanie ustawieniami, modelami, cenami i dostępem AI", "@vitnode/core:queue": "Zadania w kolejce", "@vitnode/core:queue:can_view": "Wyświetlanie zadań w kolejce", "@vitnode/core:roles": "Role", diff --git a/packages/vitnode/src/api/lib/ai/accounting.test.ts b/packages/vitnode/src/api/lib/ai/accounting.test.ts index 9725cc718..c07973fdd 100644 --- a/packages/vitnode/src/api/lib/ai/accounting.test.ts +++ b/packages/vitnode/src/api/lib/ai/accounting.test.ts @@ -470,14 +470,29 @@ describe("budget planning", () => { const chargedUsd = usd("0.002"); expect( - settlementCharge({ chargedUsd, delivered: false, unit: "points" }), + settlementCharge({ + chargedUsd, + delivered: false, + deliveredUsd: chargedUsd, + unit: "points", + }), ).toBe(0n); expect( - settlementCharge({ chargedUsd, delivered: false, unit: "usd" }), + settlementCharge({ + chargedUsd, + delivered: false, + deliveredUsd: chargedUsd, + unit: "usd", + }), ).toBe(chargedUsd); expect( formatDecimal( - settlementCharge({ chargedUsd, delivered: true, unit: "points" }), + settlementCharge({ + chargedUsd, + delivered: true, + deliveredUsd: chargedUsd, + unit: "points", + }), ), ).toBe("2"); }); diff --git a/packages/vitnode/src/api/lib/ai/admin-stats.ts b/packages/vitnode/src/api/lib/ai/admin-stats.ts new file mode 100644 index 000000000..9595317dd --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/admin-stats.ts @@ -0,0 +1,208 @@ +import { + and, + count, + eq, + gte, + inArray, + isNotNull, + lt, + sql, + sum, +} from "drizzle-orm"; + +import type { AiDatabase } from "./postgres-ledger"; + +import { core_ai_budget_periods, core_ai_runs } from "../../../database/ai"; +import { GLOBAL_SCOPE, SYSTEM_SCOPE } from "./budget"; +import { + divideByInteger, + formatDecimal, + formatDecimalOrNull, + parseDecimal, +} from "./decimal"; + +const FAILED_STATUSES = ["failed", "uncertain", "canceled"] as const; + +export interface AiBreakdownRow { + failures: number; + key: string; + knownCostUsd: string; + knownOperations: number; + operations: number; +} + +export interface AiOverview { + averageDailyCostUsd: string; + /** Average of operations whose cost is known - unknown never counts as zero. */ + averageOperationCostUsd: null | string; + budget: { + limitUsd: null | string; + remainingUsd: null | string; + reservedUsd: string; + spentUsd: string; + systemLimitUsd: null | string; + systemSpentUsd: string; + }; + byAction: AiBreakdownRow[]; + byModel: AiBreakdownRow[]; + byOrigin: AiBreakdownRow[]; + costSources: { estimated: number; provider: number; unknown: number }; + failureRate: number; + knownCostUsd: string; + operations: number; + period: { end: string; start: string }; + /** Share of operations with a known cost, 0..1. */ + pricingCoverage: number; + tokens: { input: number; output: number }; +} + +const toNumber = (value: null | number | string | undefined): number => + value === null || value === undefined ? 0 : Number(value); + +const breakdown = async ( + db: AiDatabase, + column: + | typeof core_ai_runs.actionKey + | typeof core_ai_runs.actorType + | typeof core_ai_runs.modelId, + where: ReturnType, +): Promise => { + const rows = await db + .select({ + failures: sql`count(*) filter (where ${inArray(core_ai_runs.status, [...FAILED_STATUSES])})`, + key: column, + knownCostUsd: sum(core_ai_runs.costUsd), + knownOperations: count(core_ai_runs.costUsd), + operations: count(), + }) + .from(core_ai_runs) + .where(where) + .groupBy(column) + .orderBy(sql`count(*) desc`); + + return rows.map(row => ({ + failures: toNumber(row.failures), + key: row.key ?? "unknown", + knownCostUsd: formatDecimal(parseDecimal(row.knownCostUsd ?? "0")), + knownOperations: toNumber(row.knownOperations), + operations: toNumber(row.operations), + })); +}; + +export const loadAiOverview = async ( + db: AiDatabase, + { + globalLimitUsd, + now = new Date(), + period, + systemLimitUsd, + }: { + globalLimitUsd: bigint | null; + now?: Date; + period: { end: Date; start: Date }; + systemLimitUsd: bigint | null; + }, +): Promise => { + const where = and( + gte(core_ai_runs.createdAt, period.start), + lt(core_ai_runs.createdAt, period.end), + eq(core_ai_runs.settlement, "settled"), + ); + + const [[totals], sources, byAction, byModel, byOrigin, budgets] = + await Promise.all([ + db + .select({ + failures: sql`count(*) filter (where ${inArray(core_ai_runs.status, [...FAILED_STATUSES])})`, + inputTokens: sum(core_ai_runs.inputTokens), + knownCostUsd: sum(core_ai_runs.costUsd), + knownOperations: count(core_ai_runs.costUsd), + operations: count(), + outputTokens: sum(core_ai_runs.outputTokens), + }) + .from(core_ai_runs) + .where(where), + db + .select({ source: core_ai_runs.costSource, total: count() }) + .from(core_ai_runs) + .where(where) + .groupBy(core_ai_runs.costSource), + breakdown(db, core_ai_runs.actionKey, where), + breakdown( + db, + core_ai_runs.modelId, + and(where, isNotNull(core_ai_runs.modelId)), + ), + breakdown(db, core_ai_runs.actorType, where), + db + .select() + .from(core_ai_budget_periods) + .where( + and( + inArray(core_ai_budget_periods.scopeKey, [ + GLOBAL_SCOPE, + SYSTEM_SCOPE, + ]), + eq(core_ai_budget_periods.periodStart, period.start), + ), + ), + ]); + + const global = budgets.find(row => row.scopeKey === GLOBAL_SCOPE); + const system = budgets.find(row => row.scopeKey === SYSTEM_SCOPE); + const spent = parseDecimal(global?.spentAmount ?? "0"); + const reserved = parseDecimal(global?.reservedAmount ?? "0"); + const knownCost = parseDecimal(totals.knownCostUsd ?? "0"); + const operations = toNumber(totals.operations); + const knownOperations = toNumber(totals.knownOperations); + const elapsedMs = + Math.min(now.getTime(), period.end.getTime()) - period.start.getTime(); + const elapsedDays = Math.max(1, Math.ceil(elapsedMs / 86_400_000)); + const sourceCount = (names: string[]) => + sources + .filter(row => row.source !== null && names.includes(row.source)) + .reduce((total, row) => total + toNumber(row.total), 0); + + return { + averageDailyCostUsd: formatDecimal(divideByInteger(spent, elapsedDays)), + averageOperationCostUsd: + knownOperations === 0 + ? null + : formatDecimal(divideByInteger(knownCost, knownOperations)), + budget: { + limitUsd: formatDecimalOrNull(globalLimitUsd), + remainingUsd: + globalLimitUsd === null + ? null + : formatDecimal( + globalLimitUsd - spent - reserved > 0n + ? globalLimitUsd - spent - reserved + : 0n, + ), + reservedUsd: formatDecimal(reserved), + spentUsd: formatDecimal(spent), + systemLimitUsd: formatDecimalOrNull(systemLimitUsd), + systemSpentUsd: formatDecimal(parseDecimal(system?.spentAmount ?? "0")), + }, + byAction, + byModel, + byOrigin, + costSources: { + estimated: sourceCount(["pricing", "manual", "mixed"]), + provider: sourceCount(["provider"]), + unknown: sourceCount(["unknown"]), + }, + failureRate: operations === 0 ? 0 : toNumber(totals.failures) / operations, + knownCostUsd: formatDecimal(knownCost), + operations, + period: { + end: period.end.toISOString(), + start: period.start.toISOString(), + }, + pricingCoverage: operations === 0 ? 1 : knownOperations / operations, + tokens: { + input: toNumber(totals.inputTokens), + output: toNumber(totals.outputTokens), + }, + }; +}; diff --git a/packages/vitnode/src/api/lib/ai/budget.ts b/packages/vitnode/src/api/lib/ai/budget.ts index e924860e1..a8f32cc35 100644 --- a/packages/vitnode/src/api/lib/ai/budget.ts +++ b/packages/vitnode/src/api/lib/ai/budget.ts @@ -128,26 +128,45 @@ export const findExceededBudget = ( return null; }; +/** + * What the user is charged for: the calls that produced the delivered result. + * A failed attempt before it - a timeout, a provider error - is the site's + * cost alone. A delivered call whose cost is unknown is charged the whole + * hold: unknown is never free. + */ +export const deliveredCostUsd = ( + calls: { costUsd: Decimal | null; status: string }[], + reservedUsd: Decimal, +): Decimal => { + const succeeded = calls.filter(call => call.status === "succeeded"); + if (succeeded.some(call => call.costUsd === null)) return reservedUsd; + + return succeeded.reduce((total, call) => total + (call.costUsd ?? 0n), 0n); +}; + /** * What settling one reservation moves into `spent`. * * - Money budgets always pay what the site paid. An unknown cost is charged * the full reservation - never zero. * - Personal points and daily counts are charged only when a valid result was - * delivered. A provider failure or a cancellation costs the user nothing. + * delivered, and points only for the calls that produced it. A provider + * failure or a cancellation costs the user nothing. */ export const settlementCharge = ({ chargedUsd, delivered, + deliveredUsd, unit, }: { chargedUsd: Decimal; delivered: boolean; + deliveredUsd: Decimal; unit: AiBudgetUnit; }): Decimal => { if (unit === "usd") return chargedUsd; if (!delivered) return 0n; - if (unit === "points") return usdToPoints(chargedUsd); + if (unit === "points") return usdToPoints(deliveredUsd); return decimalFromInteger(1); }; diff --git a/packages/vitnode/src/api/lib/ai/maintenance.ts b/packages/vitnode/src/api/lib/ai/maintenance.ts new file mode 100644 index 000000000..8cee3ccd4 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/maintenance.ts @@ -0,0 +1,377 @@ +import { + and, + asc, + eq, + gt, + inArray, + isNotNull, + isNull, + lt, + sql, +} from "drizzle-orm"; + +import type { AIModelDefinition } from "../../models/ai"; +import type { Decimal } from "./decimal"; +import type { AiDatabase } from "./postgres-ledger"; +import type { AiPricing } from "./pricing"; +import type { AiProviderAdapter } from "./usage-cost"; + +import { + core_ai_budget_periods, + core_ai_calls, + core_ai_cost_adjustments, + core_ai_pricing, + core_ai_reservations, + core_ai_runs, +} from "../../../database/ai"; +import { + formatDecimal, + formatDecimalOrNull, + multiplyByInteger, + parseDecimal, + parseDecimalOrNull, +} from "./decimal"; +import { usdToPoints } from "./ledger"; +import { PostgresAiLedger, summarizeCalls } from "./postgres-ledger"; +import { resolveProviderAdapter } from "./usage-cost"; + +/** + * Runs whose execution lease expired - the process died mid-call - become + * `uncertain`. Their holds are settled as unknown cost: the site is charged + * the whole reservation, because the provider may have billed work nobody + * saw. The user is charged nothing - no result reached them. + */ +export const expireAiLeases = async ( + db: AiDatabase, + now = new Date(), +): Promise => { + const expired = await db + .select({ id: core_ai_runs.id }) + .from(core_ai_runs) + .where( + and( + inArray(core_ai_runs.status, ["reserved", "running"]), + eq(core_ai_runs.settlement, "pending"), + lt(core_ai_runs.leaseExpiresAt, now), + ), + ) + .limit(100); + + const ledger = new PostgresAiLedger(db); + for (const run of expired) { + await ledger.settle(run.id, { + delivered: false, + errorCode: "AI_TIMEOUT", + finishedAt: now, + status: "uncertain", + }); + } + + return expired.length; +}; + +/** + * Replaces an estimated or unknown cost with the amount the provider billed, + * once, with an audit row. The budgets move by the difference - the earlier + * charge is corrected, never counted twice. + */ +export const applyAiCostAdjustment = async ( + db: AiDatabase, + { + billedUsd, + callId, + reason, + }: { billedUsd: Decimal; callId: number; reason: string }, +): Promise => + await db.transaction(async tx => { + const [call] = await tx + .select() + .from(core_ai_calls) + .where(eq(core_ai_calls.id, callId)) + .for("update"); + if (!call || call.costSource === "provider") return false; + + const [run] = await tx + .select() + .from(core_ai_runs) + .where(eq(core_ai_runs.id, call.runId)) + .for("update"); + if (!run) return false; + + const inserted = await tx + .insert(core_ai_cost_adjustments) + .values({ + callId, + deltaUsd: "0", + newCostUsd: formatDecimal(billedUsd), + newSource: "provider", + previousCostUsd: call.costUsd, + previousSource: call.costSource, + reason, + runId: run.id, + }) + .onConflictDoNothing() + .returning({ id: core_ai_cost_adjustments.id }); + if (inserted.length === 0) return false; + + await tx + .update(core_ai_calls) + .set({ + costReason: null, + costSource: "provider", + costUsd: formatDecimal(billedUsd), + reconciledAt: new Date(), + status: call.status === "uncertain" ? "succeeded" : call.status, + }) + .where(eq(core_ai_calls.id, callId)); + + const calls = await tx + .select() + .from(core_ai_calls) + .where(eq(core_ai_calls.runId, run.id)); + const totals = summarizeCalls(calls); + + // Only a settled run has charged anything; a pending one settles later + // from the corrected call, so its delta is zero now. + if (run.settlement !== "settled" || totals.costUsd === null) { + await tx + .update(core_ai_runs) + .set({ + costSource: totals.source, + costUsd: formatDecimalOrNull(totals.costUsd), + }) + .where(eq(core_ai_runs.id, run.id)); + + return true; + } + + const previousCharge = parseDecimalOrNull(run.chargedUsd) ?? 0n; + const delta = totals.costUsd - previousCharge; + const delivered = run.actorType === "user" && run.status === "succeeded"; + const pointsDelta = delivered ? usdToPoints(delta) : 0n; + + const holds = await tx + .select({ + budgetPeriodId: core_ai_reservations.budgetPeriodId, + unit: core_ai_budget_periods.unit, + }) + .from(core_ai_reservations) + .innerJoin( + core_ai_budget_periods, + eq(core_ai_budget_periods.id, core_ai_reservations.budgetPeriodId), + ) + .where(eq(core_ai_reservations.runId, run.id)) + .orderBy(asc(core_ai_budget_periods.scopeKey)); + for (const hold of holds) { + const change = + hold.unit === "usd" ? delta : hold.unit === "points" ? pointsDelta : 0n; + if (change === 0n) continue; + await tx + .update(core_ai_budget_periods) + .set({ + spentAmount: sql`${core_ai_budget_periods.spentAmount} + ${formatDecimal(change)}`, + unknownCount: + run.costUsd === null && hold.unit === "usd" + ? sql`GREATEST(${core_ai_budget_periods.unknownCount} - 1, 0)` + : undefined, + }) + .where(eq(core_ai_budget_periods.id, hold.budgetPeriodId)); + } + + await tx + .update(core_ai_cost_adjustments) + .set({ deltaUsd: formatDecimal(delta) }) + .where(eq(core_ai_cost_adjustments.id, inserted[0].id)); + await tx + .update(core_ai_runs) + .set({ + chargedPoints: formatDecimal( + parseDecimal(run.chargedPoints ?? "0") + pointsDelta, + ), + chargedUsd: formatDecimal(totals.costUsd), + costSource: totals.source, + costUsd: formatDecimal(totals.costUsd), + }) + .where(eq(core_ai_runs.id, run.id)); + + return true; + }); + +/** + * Asks providers that report billed cost out of band (the AI Gateway) for + * the real amount of recent estimated or unknown calls. + */ +export const reconcileAiCosts = async ( + db: AiDatabase, + adapters: AiProviderAdapter[], + { limit = 50, now = new Date() }: { limit?: number; now?: Date } = {}, +): Promise => { + const candidates = await db + .select({ + id: core_ai_calls.id, + provider: core_ai_calls.provider, + providerRequestId: core_ai_calls.providerRequestId, + }) + .from(core_ai_calls) + .where( + and( + inArray(core_ai_calls.costSource, ["manual", "pricing", "unknown"]), + isNotNull(core_ai_calls.providerRequestId), + isNull(core_ai_calls.reconciledAt), + lt(core_ai_calls.startedAt, new Date(now.getTime() - 60_000)), + gt(core_ai_calls.startedAt, new Date(now.getTime() - 7 * 86_400_000)), + ), + ) + .limit(limit); + + let adjusted = 0; + for (const call of candidates) { + const adapter = resolveProviderAdapter(adapters, call.provider); + if (!adapter.lookupCost || !call.providerRequestId) continue; + + let billed: Decimal | null; + try { + billed = await adapter.lookupCost(call.providerRequestId); + } catch { + continue; + } + if (billed !== null) { + const applied = await applyAiCostAdjustment(db, { + billedUsd: billed, + callId: call.id, + reason: `${adapter.id} reported the billed cost`, + }); + if (applied) adjusted += 1; + } else { + await db + .update(core_ai_calls) + .set({ reconciledAt: now }) + .where(eq(core_ai_calls.id, call.id)); + } + } + + return adjusted; +}; + +/** History is kept on its own schedule, never tied to the queue's cleanup. */ +export const pruneAiHistory = async ( + db: AiDatabase, + retentionDays: number, + now = new Date(), +): Promise => { + const cutoff = new Date(now.getTime() - retentionDays * 86_400_000); + await db + .delete(core_ai_runs) + .where( + and( + lt(core_ai_runs.createdAt, cutoff), + eq(core_ai_runs.settlement, "settled"), + ), + ); + await db + .delete(core_ai_budget_periods) + .where( + and( + lt(core_ai_budget_periods.periodEnd, cutoff), + eq(core_ai_budget_periods.reservedAmount, "0"), + ), + ); +}; + +/** A gateway catalog entry's per-token prices, as VitNode pricing. */ +export const pricingFromGatewayEntry = (entry: { + cacheCreationInputTokens?: string; + cachedInputTokens?: string; + input: string; + output: string; +}): AiPricing => { + const perMillion = (perToken: string) => + formatDecimal(multiplyByInteger(parseDecimal(perToken), 1_000_000)); + + return { + rates: { + inputPerMillion: perMillion(entry.input), + outputPerMillion: perMillion(entry.output), + ...(entry.cachedInputTokens + ? { cacheReadPerMillion: perMillion(entry.cachedInputTokens) } + : {}), + ...(entry.cacheCreationInputTokens + ? { cacheWritePerMillion: perMillion(entry.cacheCreationInputTokens) } + : {}), + }, + }; +}; + +export type GatewayCatalogFetcher = () => Promise<{ + models: { + id: string; + pricing?: null | { + cacheCreationInputTokens?: string; + cachedInputTokens?: string; + input: string; + output: string; + }; + }[]; +}>; + +/** + * Copies the AI Gateway's published prices onto gateway models only - a + * direct provider connection is never priced from the gateway catalog. A + * changed price becomes a new version; the old one stays for history. + */ +export const syncGatewayPricing = async ( + db: AiDatabase, + models: AIModelDefinition[], + fetchCatalog: GatewayCatalogFetcher, +): Promise<{ unchanged: string[]; unpriced: string[]; updated: string[] }> => { + const gatewayModels = models.filter(entry => typeof entry.model === "string"); + const result = { + unchanged: [] as string[], + unpriced: [] as string[], + updated: [] as string[], + }; + if (gatewayModels.length === 0) return result; + + const catalog = await fetchCatalog(); + for (const entry of gatewayModels) { + const listed = catalog.models.find(model => model.id === entry.model); + if (!listed?.pricing) { + result.unpriced.push(entry.id); + continue; + } + const pricing = pricingFromGatewayEntry(listed.pricing); + const changed = await db.transaction(async tx => { + const [current] = await tx + .select() + .from(core_ai_pricing) + .where( + and( + eq(core_ai_pricing.modelId, entry.id), + eq(core_ai_pricing.source, "sync"), + eq(core_ai_pricing.active, true), + ), + ) + .for("update"); + if ( + current && + JSON.stringify(current.pricing) === JSON.stringify(pricing) + ) { + return false; + } + if (current) { + await tx + .update(core_ai_pricing) + .set({ active: false }) + .where(eq(core_ai_pricing.id, current.id)); + } + await tx + .insert(core_ai_pricing) + .values({ modelId: entry.id, pricing, source: "sync" }); + + return true; + }); + (changed ? result.updated : result.unchanged).push(entry.id); + } + + return result; +}; diff --git a/packages/vitnode/src/api/lib/ai/memory-ledger.ts b/packages/vitnode/src/api/lib/ai/memory-ledger.ts index ce34f43d9..8d54d956b 100644 --- a/packages/vitnode/src/api/lib/ai/memory-ledger.ts +++ b/packages/vitnode/src/api/lib/ai/memory-ledger.ts @@ -15,6 +15,7 @@ import type { } from "./ledger"; import { + deliveredCostUsd, findExceededBudget, planBudgetItems, settlementCharge, @@ -276,9 +277,17 @@ export class MemoryAiLedger implements AiLedger { }); } - const costs = this.calls - .filter(call => call.runId === runId) - .map(call => parseDecimalOrNull(call.finish?.cost.amountUsd ?? null)); + const runCalls = this.calls.filter(call => call.runId === runId); + const costs = runCalls.map(call => + parseDecimalOrNull(call.finish?.cost.amountUsd ?? null), + ); + const deliveredUsd = deliveredCostUsd( + runCalls.map(call => ({ + costUsd: parseDecimalOrNull(call.finish?.cost.amountUsd ?? null), + status: call.finish?.status ?? "uncertain", + })), + run.reservedUsd, + ); const known = costs.every(cost => cost !== null); const knownUsd = sumDecimals(costs.filter((c): c is Decimal => c !== null)); const chargedUsd = known ? knownUsd : maxDecimal(run.reservedUsd, knownUsd); @@ -289,6 +298,7 @@ export class MemoryAiLedger implements AiLedger { if (!row) continue; const charge = settlementCharge({ chargedUsd, + deliveredUsd, delivered: settlement.delivered, unit: hold.unit, }); diff --git a/packages/vitnode/src/api/lib/ai/postgres-ledger.integration.test.ts b/packages/vitnode/src/api/lib/ai/postgres-ledger.integration.test.ts index 0ed1de79d..8202c9972 100644 --- a/packages/vitnode/src/api/lib/ai/postgres-ledger.integration.test.ts +++ b/packages/vitnode/src/api/lib/ai/postgres-ledger.integration.test.ts @@ -31,306 +31,347 @@ import { PostgresAiLedger } from "./postgres-ledger"; * transaction isolation and unique indexes are what make them hold, so a fake * could only ever restate the intent. */ -describePostgres("PostgresAiLedger (real PostgreSQL)", () => { - let database: TestDatabaseHandle; - let ledger: PostgresAiLedger; - let month = 0; - - /** A month of its own per test, so budgets never leak between tests. */ - const freshNow = () => { - month += 1; - - return new Date(Date.UTC(2090, month, 15, 12)); - }; - - const request = ( - overrides: Partial = {}, - ): AiReservationRequest => ({ - actionKey: "@acme/notes:summary.generate", - actorType: "user", - daily: null, - globalMonthlyUsd: null, - idempotencyKey: null, - leaseMs: 60_000, - model: { modelId: "default", provider: "mock", providerModelId: "mock-1" }, - now: new Date(), - pluginId: "@acme/notes", - promptVersion: 1, - resource: undefined, - sourceFingerprint: null, - systemConcurrency: 100, - systemMonthlyUsd: null, - timeZone: "UTC", - usd: parseDecimal("0.03"), - userConcurrency: 100, - userId: 1, - userMonthlyPoints: null, - userRequestsPerMinute: 1_000, - ...overrides, - }); - - const settle = async (runId: number, delivered = true) => - await ledger.settle(runId, { - delivered, - errorCode: null, - finishedAt: new Date(), - status: delivered ? "succeeded" : "failed", +describePostgres( + "PostgresAiLedger (real PostgreSQL)", + () => { + let database: TestDatabaseHandle; + let ledger: PostgresAiLedger; + let month = 0; + + /** A month of its own per test, so budgets never leak between tests. */ + const freshNow = () => { + month += 1; + + return new Date(Date.UTC(2090, month, 15, 12)); + }; + + const request = ( + overrides: Partial = {}, + ): AiReservationRequest => ({ + actionKey: "@acme/notes:summary.generate", + actorType: "user", + daily: null, + globalMonthlyUsd: null, + idempotencyKey: null, + leaseMs: 60_000, + model: { + modelId: "default", + provider: "mock", + providerModelId: "mock-1", + }, + now: new Date(), + pluginId: "@acme/notes", + promptVersion: 1, + resource: undefined, + sourceFingerprint: null, + systemConcurrency: 100, + systemMonthlyUsd: null, + timeZone: "UTC", + usd: parseDecimal("0.03"), + userConcurrency: 100, + userId: 1, + userMonthlyPoints: null, + userRequestsPerMinute: 1_000, + ...overrides, }); - const finishCall = async (runId: number, costUsd: null | string) => { - const callId = await ledger.beginCall({ - attempt: 1, - modelId: "default", - provider: "mock", - providerModelId: "mock-1", - runId, - startedAt: new Date(), - }); - await ledger.finishCall(callId, { - cost: - costUsd === null - ? { amountUsd: null, reason: "no pricing", source: "unknown" } - : { amountUsd: costUsd, pricingVersion: "test", source: "pricing" }, - errorCode: null, - finishedAt: new Date(), - images: 0, - pricingSnapshot: null, - providerModelId: "mock-1", - providerRequestId: "req", - status: "succeeded", - usage: { - cacheReadTokens: 0, - cacheWriteTokens: 0, - inputTokens: 10, - outputTokens: 5, - reasoningTokens: null, - }, + const settle = async (runId: number, delivered = true) => + await ledger.settle(runId, { + delivered, + errorCode: null, + finishedAt: new Date(), + status: delivered ? "succeeded" : "failed", + }); + + const finishCall = async (runId: number, costUsd: null | string) => { + const callId = await ledger.beginCall({ + attempt: 1, + modelId: "default", + provider: "mock", + providerModelId: "mock-1", + runId, + startedAt: new Date(), + }); + await ledger.finishCall(callId, { + cost: + costUsd === null + ? { amountUsd: null, reason: "no pricing", source: "unknown" } + : { amountUsd: costUsd, pricingVersion: "test", source: "pricing" }, + errorCode: null, + finishedAt: new Date(), + images: 0, + pricingSnapshot: null, + providerModelId: "mock-1", + providerRequestId: "req", + status: "succeeded", + usage: { + cacheReadTokens: 0, + cacheWriteTokens: 0, + inputTokens: 10, + outputTokens: 5, + reasoningTokens: null, + }, + }); + }; + + const budgetRow = async (scopeKey: string, now: Date) => { + const rows = await database.db + .select() + .from(core_ai_budget_periods) + .where(eq(core_ai_budget_periods.scopeKey, scopeKey)); + + return rows.find( + row => + row.periodStart.getTime() <= now.getTime() && + row.periodEnd.getTime() > now.getTime(), + ); + }; + + beforeAll(async () => { + database = await createTestDatabase({ + ...aiTables, + ...filesTables, + ...languagesTables, + ...rolesTables, + ...usersTables, + }); + ledger = new PostgresAiLedger(database.db); + await database.db.insert(core_languages).values({ + code: "en", + default: true, + name: "English", + }); + await database.db.insert(core_roles).values([ + { id: 1, updatedAt: new Date() }, + { id: 2, updatedAt: new Date() }, + { id: 3, root: true, updatedAt: new Date() }, + ]); + await database.db.insert(core_users).values( + [1, 2, 3, 4, 5].map(id => ({ + avatarColor: "000000", + email: `user${id}@test.test`, + id, + ipAddress: "127.0.0.1", + name: `user${id}`, + nameCode: `user${id}`, + roleId: id === 5 ? 3 : 1, + })), + ); + }, 60_000); + + afterAll(async () => { + await database?.drop(); }); - }; - - const budgetRow = async (scopeKey: string, now: Date) => { - const rows = await database.db - .select() - .from(core_ai_budget_periods) - .where(eq(core_ai_budget_periods.scopeKey, scopeKey)); - - return rows.find( - row => - row.periodStart.getTime() <= now.getTime() && - row.periodEnd.getTime() > now.getTime(), - ); - }; - - beforeAll(async () => { - database = await createTestDatabase({ - ...aiTables, - ...filesTables, - ...languagesTables, - ...rolesTables, - ...usersTables, + + it("lets concurrent runs take only the global budget that exists", async () => { + const now = freshNow(); + const attempts = await Promise.all( + Array.from( + { length: 12 }, + async () => + await ledger.reserve( + request({ globalMonthlyUsd: parseDecimal("0.1"), now }), + ), + ), + ); + + expect(attempts.filter(result => result.ok)).toHaveLength(3); + expect( + attempts + .filter(result => !result.ok) + .map(result => !result.ok && result.code), + ).toEqual(Array(9).fill("AI_BUDGET_EXHAUSTED")); + const global = await budgetRow("global", now); + expect(global?.reservedAmount).toBe("0.090000000000"); }); - ledger = new PostgresAiLedger(database.db); - await database.db.insert(core_languages).values({ - code: "en", - default: true, - name: "English", + + it("lets concurrent runs of one user take only the points they have", async () => { + const now = freshNow(); + const attempts = await Promise.all( + Array.from( + { length: 8 }, + async () => + await ledger.reserve( + request({ + now, + userId: 2, + userMonthlyPoints: parseDecimal("50"), + }), + ), + ), + ); + + expect(attempts.filter(result => result.ok)).toHaveLength(1); + expect( + attempts.find( + result => !result.ok && result.code === "AI_USER_LIMIT_REACHED", + ), + ).toBeDefined(); }); - await database.db.insert(core_roles).values([ - { id: 1, updatedAt: new Date() }, - { id: 2, updatedAt: new Date() }, - { id: 3, root: true, updatedAt: new Date() }, - ]); - await database.db.insert(core_users).values( - [1, 2, 3, 4, 5].map(id => ({ - avatarColor: "000000", - email: `user${id}@test.test`, - id, - ipAddress: "127.0.0.1", - name: `user${id}`, - nameCode: `user${id}`, - roleId: id === 5 ? 3 : 1, - })), - ); - }, 60_000); - - afterAll(async () => { - await database?.drop(); - }); - - it("lets concurrent runs take only the global budget that exists", async () => { - const now = freshNow(); - const attempts = await Promise.all( - Array.from({ length: 12 }, async () => - await ledger.reserve( - request({ globalMonthlyUsd: parseDecimal("0.1"), now }), + + it("enforces per-user concurrency under a race", async () => { + const now = freshNow(); + const attempts = await Promise.all( + Array.from( + { length: 6 }, + async () => + await ledger.reserve( + request({ now, userConcurrency: 2, userId: 3 }), + ), ), - ), - ); - - expect(attempts.filter(result => result.ok)).toHaveLength(3); - expect( - attempts.filter(result => !result.ok).map(result => !result.ok && result.code), - ).toEqual(Array(9).fill("AI_BUDGET_EXHAUSTED")); - const global = await budgetRow("global", now); - expect(global?.reservedAmount).toBe("0.090000000000"); - }); - - it("lets concurrent runs of one user take only the points they have", async () => { - const now = freshNow(); - const attempts = await Promise.all( - Array.from({ length: 8 }, async () => - await ledger.reserve( - request({ now, userId: 2, userMonthlyPoints: parseDecimal("50") }), + ); + + expect(attempts.filter(result => result.ok)).toHaveLength(2); + }); + + it("settles a run exactly once, however many settle at the same time", async () => { + const now = freshNow(); + const reserved = await ledger.reserve(request({ now, userId: 4 })); + if (!reserved.ok) throw new Error(reserved.code); + await finishCall(reserved.runId, "0.012"); + + const results = await Promise.all( + Array.from({ length: 5 }, async () => await settle(reserved.runId)), + ); + + expect(results.filter(result => result.applied)).toHaveLength(1); + const user = await budgetRow("user:4", now); + expect(user?.spentAmount).toBe("12.000000000000"); + expect(user?.reservedAmount).toBe("0.000000000000"); + const global = await budgetRow("global", now); + expect(global?.spentAmount).toBe("0.012000000000"); + }); + + it("charges the site the whole hold for an unknown cost, and the user nothing for a failure", async () => { + const now = freshNow(); + const reserved = await ledger.reserve(request({ now, userId: 4 })); + if (!reserved.ok) throw new Error(reserved.code); + await finishCall(reserved.runId, null); + + const result = await settle(reserved.runId, false); + + expect(result).toMatchObject({ applied: true, costKnown: false }); + expect(formatDecimal(result.chargedUsd)).toBe("0.03"); + const global = await budgetRow("global", now); + expect(global).toMatchObject({ + spentAmount: "0.030000000000", + unknownCount: 1, + }); + const user = await budgetRow("user:4", now); + expect(user?.spentAmount).toBe("0.000000000000"); + }); + + it("runs a duplicated idempotency key once, even when both race", async () => { + const now = freshNow(); + const results = await Promise.all( + Array.from( + { length: 4 }, + async () => + await ledger.reserve( + request({ idempotencyKey: "same", now, userId: 4 }), + ), ), - ), - ); - - expect(attempts.filter(result => result.ok)).toHaveLength(1); - expect( - attempts.find(result => !result.ok && result.code === "AI_USER_LIMIT_REACHED"), - ).toBeDefined(); - }); - - it("enforces per-user concurrency under a race", async () => { - const now = freshNow(); - const attempts = await Promise.all( - Array.from({ length: 6 }, async () => - await ledger.reserve(request({ now, userConcurrency: 2, userId: 3 })), - ), - ); - - expect(attempts.filter(result => result.ok)).toHaveLength(2); - }); - - it("settles a run exactly once, however many settle at the same time", async () => { - const now = freshNow(); - const reserved = await ledger.reserve(request({ now, userId: 4 })); - if (!reserved.ok) throw new Error(reserved.code); - await finishCall(reserved.runId, "0.012"); - - const results = await Promise.all( - Array.from({ length: 5 }, async () => await settle(reserved.runId)), - ); - - expect(results.filter(result => result.applied)).toHaveLength(1); - const user = await budgetRow("user:4", now); - expect(user?.spentAmount).toBe("12.000000000000"); - expect(user?.reservedAmount).toBe("0.000000000000"); - const global = await budgetRow("global", now); - expect(global?.spentAmount).toBe("0.012000000000"); - }); - - it("charges the site the whole hold for an unknown cost, and the user nothing for a failure", async () => { - const now = freshNow(); - const reserved = await ledger.reserve(request({ now, userId: 4 })); - if (!reserved.ok) throw new Error(reserved.code); - await finishCall(reserved.runId, null); - - const result = await settle(reserved.runId, false); - - expect(result).toMatchObject({ applied: true, costKnown: false }); - expect(formatDecimal(result.chargedUsd)).toBe("0.03"); - const global = await budgetRow("global", now); - expect(global).toMatchObject({ spentAmount: "0.030000000000", unknownCount: 1 }); - const user = await budgetRow("user:4", now); - expect(user?.spentAmount).toBe("0.000000000000"); - }); - - it("runs a duplicated idempotency key once, even when both race", async () => { - const now = freshNow(); - const results = await Promise.all( - Array.from({ length: 4 }, async () => - await ledger.reserve(request({ idempotencyKey: "same", now, userId: 4 })), - ), - ); - - expect(results.filter(result => result.ok)).toHaveLength(1); - expect( - results.filter(result => !result.ok && result.code === "AI_DUPLICATE_REQUEST"), - ).toHaveLength(3); - }); - - it("never deadlocks when users and the system reserve at once", async () => { - const now = freshNow(); - const results = await Promise.all( - Array.from({ length: 30 }, async (_, index) => - await ledger.reserve( - request({ - actorType: index % 3 === 0 ? "system" : "user", - daily: - index % 3 === 0 - ? null - : { limit: 100, permissionKey: "@acme/notes:summary" }, - now, - userId: index % 3 === 0 ? null : (index % 2) + 1, - }), + ); + + expect(results.filter(result => result.ok)).toHaveLength(1); + expect( + results.filter( + result => !result.ok && result.code === "AI_DUPLICATE_REQUEST", ), - ), - ); - - expect(results.every(result => result.ok)).toBe(true); - }); - - it("resolves policies from real role tables: largest allowance, override wins", async () => { - await database.db.insert(core_users_secondary_roles).values({ roleId: 2, userId: 1 }); - await database.db.insert(core_ai_role_policies).values([ - { monthlyPoints: "100", roleId: 1 }, - { monthlyPoints: "400", roleId: 2 }, - ]); - await database.db.insert(core_ai_role_permissions).values([ - { dailyLimit: 5, permission: "@acme/notes:summary", roleId: 1 }, - { dailyLimit: 9, permission: "@acme/notes:summary", roleId: 2 }, - ]); - - const policy = await ledger.resolveUserPolicy({ - defaultGranted: false, - permissionKey: "@acme/notes:summary", - userId: 1, + ).toHaveLength(3); }); - expect(policy).toEqual({ - dailyLimit: 9, - granted: true, - monthlyPoints: parseDecimal("400"), + it("never deadlocks when users and the system reserve at once", async () => { + const now = freshNow(); + const results = await Promise.all( + Array.from( + { length: 30 }, + async (_, index) => + await ledger.reserve( + request({ + actorType: index % 3 === 0 ? "system" : "user", + daily: + index % 3 === 0 + ? null + : { limit: 100, permissionKey: "@acme/notes:summary" }, + now, + userId: index % 3 === 0 ? null : (index % 2) + 1, + }), + ), + ), + ); + + expect(results.every(result => result.ok)).toBe(true); }); - await database.db - .insert(core_ai_user_overrides) - .values({ monthlyPoints: "7", userId: 1 }); - const overridden = await ledger.resolveUserPolicy({ - defaultGranted: false, - permissionKey: "@acme/notes:summary", - userId: 1, + it("resolves policies from real role tables: largest allowance, override wins", async () => { + await database.db + .insert(core_users_secondary_roles) + .values({ roleId: 2, userId: 1 }); + await database.db.insert(core_ai_role_policies).values([ + { monthlyPoints: "100", roleId: 1 }, + { monthlyPoints: "400", roleId: 2 }, + ]); + await database.db.insert(core_ai_role_permissions).values([ + { dailyLimit: 5, permission: "@acme/notes:summary", roleId: 1 }, + { dailyLimit: 9, permission: "@acme/notes:summary", roleId: 2 }, + ]); + + const policy = await ledger.resolveUserPolicy({ + defaultGranted: false, + permissionKey: "@acme/notes:summary", + userId: 1, + }); + + expect(policy).toEqual({ + dailyLimit: 9, + granted: true, + monthlyPoints: parseDecimal("400"), + }); + + await database.db + .insert(core_ai_user_overrides) + .values({ monthlyPoints: "7", userId: 1 }); + const overridden = await ledger.resolveUserPolicy({ + defaultGranted: false, + permissionKey: "@acme/notes:summary", + userId: 1, + }); + expect(overridden.monthlyPoints).toBe(parseDecimal("7")); + + const root = await ledger.resolveUserPolicy({ + defaultGranted: false, + permissionKey: "@acme/notes:other", + userId: 5, + }); + expect(root).toMatchObject({ granted: true, monthlyPoints: null }); }); - expect(overridden.monthlyPoints).toBe(parseDecimal("7")); - const root = await ledger.resolveUserPolicy({ - defaultGranted: false, - permissionKey: "@acme/notes:other", - userId: 5, + it("keeps consumed usage when a user's roles change", async () => { + const now = freshNow(); + const reserved = await ledger.reserve( + request({ now, userId: 3, userMonthlyPoints: parseDecimal("100") }), + ); + if (!reserved.ok) throw new Error(reserved.code); + await finishCall(reserved.runId, "0.05"); + await settle(reserved.runId); + + // A smaller allowance after a role change: the 50 points already spent stay spent. + const next = await ledger.reserve( + request({ + now, + userId: 3, + usd: parseDecimal("0.02"), + userMonthlyPoints: parseDecimal("60"), + }), + ); + + expect(next).toMatchObject({ code: "AI_USER_LIMIT_REACHED", ok: false }); + const [run] = await database.db + .select({ chargedPoints: core_ai_runs.chargedPoints }) + .from(core_ai_runs) + .where(eq(core_ai_runs.id, reserved.runId)); + expect(run.chargedPoints).toBe("50.000000000000"); }); - expect(root).toMatchObject({ granted: true, monthlyPoints: null }); - }); - - it("keeps consumed usage when a user's roles change", async () => { - const now = freshNow(); - const reserved = await ledger.reserve( - request({ now, userId: 3, userMonthlyPoints: parseDecimal("100") }), - ); - if (!reserved.ok) throw new Error(reserved.code); - await finishCall(reserved.runId, "0.05"); - await settle(reserved.runId); - - // A smaller allowance after a role change: the 50 points already spent stay spent. - const next = await ledger.reserve( - request({ now, userId: 3, usd: parseDecimal("0.02"), userMonthlyPoints: parseDecimal("60") }), - ); - - expect(next).toMatchObject({ code: "AI_USER_LIMIT_REACHED", ok: false }); - const [run] = await database.db - .select({ chargedPoints: core_ai_runs.chargedPoints }) - .from(core_ai_runs) - .where(eq(core_ai_runs.id, reserved.runId)); - expect(run.chargedPoints).toBe("50.000000000000"); - }); -}); + }, +); diff --git a/packages/vitnode/src/api/lib/ai/postgres-ledger.ts b/packages/vitnode/src/api/lib/ai/postgres-ledger.ts index 8eee070f5..7e8f9bdd1 100644 --- a/packages/vitnode/src/api/lib/ai/postgres-ledger.ts +++ b/packages/vitnode/src/api/lib/ai/postgres-ledger.ts @@ -37,6 +37,7 @@ import { } from "../../../database/users"; import { PG_ERROR_CODES, pgErrorCode } from "../../../lib/api/pg-error"; import { + deliveredCostUsd, findExceededBudget, planBudgetItems, settlementCharge, @@ -562,6 +563,13 @@ export class PostgresAiLedger implements AiLedger { for (const hold of holds) { const charge = settlementCharge({ chargedUsd, + deliveredUsd: deliveredCostUsd( + calls.map(call => ({ + costUsd: parseDecimalOrNull(call.costUsd), + status: call.status, + })), + reservedUsd, + ), delivered: settlement.delivered, unit: hold.unit, }); diff --git a/packages/vitnode/src/api/lib/ai/registry.ts b/packages/vitnode/src/api/lib/ai/registry.ts index a9bb3ded7..a6db6e489 100644 --- a/packages/vitnode/src/api/lib/ai/registry.ts +++ b/packages/vitnode/src/api/lib/ai/registry.ts @@ -1,11 +1,11 @@ +import type { z } from "zod"; + import type { AiActionDefaults, AiActionOutputMode, AiActorType, AnyAiActionDefinition, } from "./action"; -import type { z } from "zod"; - import type { AiModelCapability } from "./capabilities"; import { AiActionDefinitionError } from "./action"; diff --git a/packages/vitnode/src/api/lib/ai/run-schemas.ts b/packages/vitnode/src/api/lib/ai/run-schemas.ts new file mode 100644 index 000000000..3300cc936 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/run-schemas.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +/** What a browser may send to run an action. Never a model, price or actor. */ +export const zodAiRunBody = z.object({ + action: z.string().min(3).max(255), + /** Retries of one user gesture reuse the key, so they never charge twice. */ + idempotencyKey: z.string().min(8).max(128).optional(), + input: z.unknown(), + /** Recorded with the run; access is still decided by the action's authorize(). */ + resource: z + .object({ + id: z.union([z.string().max(255), z.number()]).nullable(), + type: z.string().min(1).max(100), + }) + .optional(), + sourceFingerprint: z.string().min(1).max(64).optional(), +}); + +export const zodAiRunResponse = z.object({ + output: z.unknown(), + runId: z.number(), + usage: z.object({ + chargedPoints: z.string(), + costKnown: z.boolean(), + modelId: z.string(), + }), +}); + +export const zodAiErrorBody = z.object({ + code: z.string(), + message: z.string(), + resetsAt: z.string().optional(), + runId: z.number().optional(), +}); diff --git a/packages/vitnode/src/api/lib/ai/runner.test.ts b/packages/vitnode/src/api/lib/ai/runner.test.ts index 099102f2e..964f8de44 100644 --- a/packages/vitnode/src/api/lib/ai/runner.test.ts +++ b/packages/vitnode/src/api/lib/ai/runner.test.ts @@ -220,6 +220,32 @@ describe("AiRunner.run", () => { expect(result.usage.chargedPoints).toBe("20"); }); + it("charges the user only for the call that delivered, never for a lost attempt", async () => { + let calls = 0; + const model = new MockLanguageModelV4({ + doGenerate: async () => { + calls += 1; + if (calls === 1) throw new TypeError("socket hang up"); + + return Promise.resolve(generated("Done.")); + }, + }); + const ledger = new MemoryAiLedger({ + actionSettings: { [SUMMARY]: { maxRetries: 1 } }, + }); + const { runner } = setup({ ledger, models: [textModel(model)] }); + + const result = await runner.run({ action: SUMMARY, input: { text: "x" } }); + + // The lost attempt's cost is unknown: the site pays the whole hold, + // the user pays only the 20 points of the answer they got. + expect(result.usage).toMatchObject({ + chargedPoints: "20", + costKnown: false, + }); + expect(ledger.spentUsd()).toBe(ledger.runs[0].reservedUsd); + }); + it("falls back to a compatible model after the primary fails", async () => { const ledger = new MemoryAiLedger({ actionSettings: { diff --git a/packages/vitnode/src/api/lib/ai/runner.ts b/packages/vitnode/src/api/lib/ai/runner.ts index 64db98c76..6e42c24bd 100644 --- a/packages/vitnode/src/api/lib/ai/runner.ts +++ b/packages/vitnode/src/api/lib/ai/runner.ts @@ -884,18 +884,20 @@ export class AiRunner { runId, new Date(Date.now() + this.leaseMs(plan)), ); - callId = await this.ledger.beginCall({ - attempt: 1, - modelId: model.definition.id, - provider: model.provider, - providerModelId: model.providerModelId, - runId, - startedAt: new Date(), - }); }, pull: async streamController => { try { if (!activeStream) { + // Recorded on the first read, right before the provider call: a + // stream nobody reads costs nothing and records no call. + callId = await this.ledger.beginCall({ + attempt: 1, + modelId: model.definition.id, + provider: model.provider, + providerModelId: model.providerModelId, + runId, + startedAt: new Date(), + }); activeStream = streamText({ ...promptArgs(plan.prompt), abortSignal: combineSignals(controller.signal, plan.timeoutMs), diff --git a/packages/vitnode/src/api/lib/ai/settings-store.ts b/packages/vitnode/src/api/lib/ai/settings-store.ts new file mode 100644 index 000000000..590b09f6f --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/settings-store.ts @@ -0,0 +1,30 @@ +import { eq } from "drizzle-orm"; + +import type { AiDatabase } from "./postgres-ledger"; + +import { core_ai_settings } from "../../../database/ai"; + +export type AiSettingsRow = typeof core_ai_settings.$inferInsert; + +/** Writes the single settings row, creating it on first save. */ +export const saveAiSettings = async ( + db: AiDatabase, + values: Omit, +): Promise => { + const [existing] = await db + .select({ id: core_ai_settings.id }) + .from(core_ai_settings) + .where(eq(core_ai_settings.id, 1)); + if (existing) { + await db + .update(core_ai_settings) + .set(values) + .where(eq(core_ai_settings.id, 1)); + + return; + } + await db + .insert(core_ai_settings) + .values({ ...values, id: 1 }) + .onConflictDoUpdate({ set: values, target: core_ai_settings.id }); +}; diff --git a/packages/vitnode/src/api/lib/ai/usage-summary.ts b/packages/vitnode/src/api/lib/ai/usage-summary.ts new file mode 100644 index 000000000..fe96b4fd8 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/usage-summary.ts @@ -0,0 +1,173 @@ +import type { Context } from "hono"; + +import { and, eq } from "drizzle-orm"; + +import type { AiLedger } from "./ledger"; + +import { core_ai_budget_periods } from "../../../database/ai"; +import { GLOBAL_SCOPE, userDailyScope, userScope } from "./budget"; +import { formatDecimal, formatDecimalOrNull, parseDecimal } from "./decimal"; +import { periodContaining } from "./periods"; + +/** Notice thresholds shown to users. */ +export const AI_USAGE_WARNING_RATIO = 0.8; + +export interface AiUserActionUsage { + dailyLimit: null | number; + description: null | string; + key: string; + permissionKey: string; + usedToday: number; +} + +export interface AiUserUsage { + actions: AiUserActionUsage[]; + /** `false` when an admin switched AI off for everyone. */ + enabled: boolean; + notice: "exhausted" | "near_limit" | "none" | "site_paused"; + points: { + available: null | string; + reserved: string; + /** `null` is unlimited. */ + total: null | string; + used: string; + }; + resetsAt: string; + /** The site budget, not the user's own limit, is what stops them. */ + sitePaused: boolean; +} + +const readRow = async ( + db: Context["var"]["db"], + scopeKey: string, + periodStart: Date, +) => { + const [row] = await db + .select() + .from(core_ai_budget_periods) + .where( + and( + eq(core_ai_budget_periods.scopeKey, scopeKey), + eq(core_ai_budget_periods.periodStart, periodStart), + ), + ) + .limit(1); + + return row; +}; + +/** + * One user's own AI allowance. Always scoped by the session's user id - no + * parameter can point it at somebody else. + */ +export const loadUserAiUsage = async ( + c: Context, + ledger: AiLedger, + userId: number, +): Promise => { + const db = c.get("db"); + const settings = await ledger.loadSettings(); + const now = new Date(); + const month = periodContaining(now, "month", settings.timeZone); + const day = periodContaining(now, "day", settings.timeZone); + + const userActions = c + .get("ai") + .actions() + .all() + .filter(action => action.definition.actors.includes("user")); + + const policies = new Map< + string, + Awaited> + >(); + for (const action of userActions) { + if (policies.has(action.permissionKey)) continue; + policies.set( + action.permissionKey, + await ledger.resolveUserPolicy({ + defaultGranted: action.definition.permission.defaultGranted, + permissionKey: action.permissionKey, + userId, + }), + ); + } + const allowance = + [...policies.values()][0] ?? + (await ledger.resolveUserPolicy({ + defaultGranted: false, + permissionKey: "", + userId, + })); + + const [own, global] = await Promise.all([ + readRow(db, userScope(userId), month.start), + readRow(db, GLOBAL_SCOPE, month.start), + ]); + const used = parseDecimal(own?.spentAmount ?? "0"); + const reserved = parseDecimal(own?.reservedAmount ?? "0"); + const total = allowance.monthlyPoints; + const available = + total === null + ? null + : total - used - reserved > 0n + ? total - used - reserved + : 0n; + + const globalLimit = settings.monthlyBudgetUsd; + const sitePaused = + !settings.enabled || + (globalLimit !== null && + parseDecimal(global?.spentAmount ?? "0") + + parseDecimal(global?.reservedAmount ?? "0") >= + globalLimit); + + const actions: AiUserActionUsage[] = []; + for (const action of userActions) { + const policy = policies.get(action.permissionKey); + if (!policy?.granted) continue; + const daily = await readRow( + db, + userDailyScope(userId, action.permissionKey), + day.start, + ); + const actionSettings = await ledger.loadActionSettings(action.key); + if (actionSettings?.enabled === false) continue; + actions.push({ + // The same order the runner enforces: role grant, admin setting, default. + dailyLimit: + policy.dailyLimit ?? + actionSettings?.dailyLimit ?? + action.definition.defaults.dailyLimit, + description: action.definition.description ?? null, + key: action.key, + permissionKey: action.permissionKey, + usedToday: Number(parseDecimal(daily?.spentAmount ?? "0") / 10n ** 12n), + }); + } + + let notice: AiUserUsage["notice"] = "none"; + if (sitePaused) notice = "site_paused"; + else if (total !== null && available === 0n) notice = "exhausted"; + else if ( + total !== null && + total > 0n && + Number(used + reserved) >= Number(total) * AI_USAGE_WARNING_RATIO + ) { + notice = "near_limit"; + } + + return { + actions, + enabled: settings.enabled, + notice, + points: { + available: formatDecimalOrNull(available), + reserved: formatDecimal(reserved), + total: formatDecimalOrNull(total), + used: formatDecimal(used), + }, + resetsAt: month.end.toISOString(), + sitePaused, + }; +}; diff --git a/packages/vitnode/src/api/modules/admin/admin.module.ts b/packages/vitnode/src/api/modules/admin/admin.module.ts index 60ebd591f..b89d203a6 100644 --- a/packages/vitnode/src/api/modules/admin/admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/admin.module.ts @@ -2,6 +2,7 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; import { advancedAdminModule } from "./advanced/advanced.admin.module"; +import { aiAdminModule } from "./ai/ai.admin.module"; import { dashboardAdminModule } from "./dashboard/dashboard.admin.module"; import { debugAdminModule } from "./debug/debug.admin.module"; import { filesAdminModule } from "./files/files.admin.module"; @@ -18,6 +19,7 @@ export const adminModule = buildModule({ name: "admin", routes: [sessionAdminRoute, sendNotificationRoute], modules: [ + aiAdminModule, usersAdminModule, rolesAdminModule, staffAdminModule, diff --git a/packages/vitnode/src/api/modules/admin/ai/ai.admin.module.ts b/packages/vitnode/src/api/modules/admin/ai/ai.admin.module.ts new file mode 100644 index 000000000..00daa8a4a --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/ai.admin.module.ts @@ -0,0 +1,50 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + deleteAiUserOverrideAdminRoute, + getAiAccessAdminRoute, + updateAiRoleAccessAdminRoute, + updateAiUserOverrideAdminRoute, +} from "./routes/access.route"; +import { listAiActionsAdminRoute } from "./routes/actions.route"; +import { assistAiAdminRoute } from "./routes/assist.route"; +import { + listAiHistoryAdminRoute, + showAiRunAdminRoute, +} from "./routes/history.route"; +import { listAiModelsAdminRoute } from "./routes/models.route"; +import { aiOverviewAdminRoute } from "./routes/overview.route"; +import { getAiSettingsAdminRoute } from "./routes/settings.route"; +import { syncAiPricingAdminRoute } from "./routes/sync-pricing.route"; +import { testAiActionAdminRoute } from "./routes/test-action.route"; +import { updateAiActionAdminRoute } from "./routes/update-action.route"; +import { + deleteAiPricingAdminRoute, + updateAiPricingAdminRoute, +} from "./routes/update-pricing.route"; +import { updateAiSettingsAdminRoute } from "./routes/update-settings.route"; + +export const aiAdminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "ai", + routes: [ + aiOverviewAdminRoute, + getAiSettingsAdminRoute, + updateAiSettingsAdminRoute, + listAiModelsAdminRoute, + updateAiPricingAdminRoute, + deleteAiPricingAdminRoute, + syncAiPricingAdminRoute, + listAiActionsAdminRoute, + updateAiActionAdminRoute, + testAiActionAdminRoute, + getAiAccessAdminRoute, + updateAiRoleAccessAdminRoute, + updateAiUserOverrideAdminRoute, + deleteAiUserOverrideAdminRoute, + listAiHistoryAdminRoute, + showAiRunAdminRoute, + assistAiAdminRoute, + ], +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts new file mode 100644 index 000000000..f640ebcaa --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts @@ -0,0 +1,321 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; + +import { formatDecimal, parseDecimal } from "@/api/lib/ai/decimal"; +import { resolveRoleNames } from "@/api/lib/resolve-role-names"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { + core_ai_role_permissions, + core_ai_role_policies, + core_ai_user_overrides, +} from "@/database/ai"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; + +const zodRoleName = z.array( + z.object({ languageCode: z.string(), name: z.string() }), +); + +export const getAiAccessAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_view" }, + route: { + method: "get", + description: + "Who may use which AI feature: role grants, allowances and user exceptions.", + path: "/access", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + overrides: z.array( + z.object({ + blocked: z.boolean(), + monthlyPoints: z.string().nullable(), + unlimited: z.boolean(), + user: z.object({ + id: z.number(), + name: z.string(), + nameCode: z.string(), + }), + }), + ), + permissions: z.array( + z.object({ + actions: z.array(z.string()), + defaultGranted: z.boolean(), + key: z.string(), + }), + ), + roles: z.array( + z.object({ + grants: z.array( + z.object({ + dailyLimit: z.number().nullable(), + granted: z.boolean(), + permission: z.string(), + }), + ), + id: z.number(), + monthlyPoints: z.string().nullable(), + name: zodRoleName, + root: z.boolean(), + unlimited: z.boolean(), + }), + ), + }), + }, + }, + description: "AI access", + }, + }, + }, + handler: async c => { + const db = c.get("db"); + const [roles, policies, grants, overrides] = await Promise.all([ + db + .select({ id: core_roles.id, root: core_roles.root }) + .from(core_roles) + .where(eq(core_roles.guest, false)) + .orderBy(asc(core_roles.id)), + db.select().from(core_ai_role_policies), + db.select().from(core_ai_role_permissions), + db + .select({ + blocked: core_ai_user_overrides.blocked, + id: core_users.id, + monthlyPoints: core_ai_user_overrides.monthlyPoints, + name: core_users.name, + nameCode: core_users.nameCode, + unlimited: core_ai_user_overrides.unlimited, + }) + .from(core_ai_user_overrides) + .innerJoin(core_users, eq(core_users.id, core_ai_user_overrides.userId)) + .orderBy(asc(core_users.name)), + ]); + const names = await resolveRoleNames( + c, + roles.map(role => role.id), + ); + + const permissions = new Map< + string, + { actions: string[]; defaultGranted: boolean } + >(); + for (const action of c.get("ai").actions().all()) { + if (!action.definition.actors.includes("user")) continue; + const entry = permissions.get(action.permissionKey) ?? { + actions: [], + defaultGranted: action.definition.permission.defaultGranted, + }; + entry.actions.push(action.key); + permissions.set(action.permissionKey, entry); + } + + return c.json( + { + overrides: overrides.map(row => ({ + blocked: row.blocked, + monthlyPoints: + row.monthlyPoints === null + ? null + : formatDecimal(parseDecimal(row.monthlyPoints)), + unlimited: row.unlimited, + user: { id: row.id, name: row.name, nameCode: row.nameCode }, + })), + permissions: [...permissions.entries()].map(([key, value]) => ({ + key, + ...value, + })), + roles: roles.map(role => { + const policy = policies.find(row => row.roleId === role.id); + + return { + grants: grants + .filter(row => row.roleId === role.id) + .map(row => ({ + dailyLimit: row.dailyLimit, + granted: row.granted, + permission: row.permission, + })), + id: role.id, + monthlyPoints: + policy?.monthlyPoints === null || + policy?.monthlyPoints === undefined + ? null + : formatDecimal(parseDecimal(policy.monthlyPoints)), + name: names.get(role.id) ?? [], + root: role.root, + unlimited: policy?.unlimited ?? false, + }; + }), + }, + 200, + ); + }, +}); + +const zodDecimal = z.string().regex(/^\d+(\.\d{1,12})?$/); + +export const updateAiRoleAccessAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_manage" }, + route: { + method: "put", + description: + "Set one role's AI allowance and which AI permissions it grants. A `granted` of null removes the row, so the action's default applies.", + path: "/access/roles", + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + grants: z.array( + z.object({ + dailyLimit: z.number().int().min(0).max(100_000).nullable(), + granted: z.boolean().nullable(), + permission: z.string().min(3).max(255), + }), + ), + monthlyPoints: zodDecimal.nullable(), + roleId: z.number().int(), + unlimited: z.boolean(), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Saved", + }, + }, + }, + handler: async c => { + const { grants, monthlyPoints, roleId, unlimited } = c.req.valid("json"); + await c.get("db").transaction(async tx => { + await tx + .insert(core_ai_role_policies) + .values({ monthlyPoints, roleId, unlimited }) + .onConflictDoUpdate({ + set: { monthlyPoints, unlimited }, + target: core_ai_role_policies.roleId, + }); + + const removed = grants + .filter(grant => grant.granted === null) + .map(grant => grant.permission); + if (removed.length > 0) { + await tx + .delete(core_ai_role_permissions) + .where( + and( + inArray(core_ai_role_permissions.permission, removed), + eq(core_ai_role_permissions.roleId, roleId), + ), + ); + } + for (const grant of grants) { + if (grant.granted === null) continue; + await tx + .insert(core_ai_role_permissions) + .values({ + dailyLimit: grant.dailyLimit, + granted: grant.granted, + permission: grant.permission, + roleId, + }) + .onConflictDoUpdate({ + set: { dailyLimit: grant.dailyLimit, granted: grant.granted }, + target: [ + core_ai_role_permissions.roleId, + core_ai_role_permissions.permission, + ], + }); + } + }); + + return c.json({ ok: true as const }, 200); + }, +}); + +export const updateAiUserOverrideAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_manage" }, + route: { + method: "put", + description: + "Give one user an explicit AI exception: a different allowance, unlimited, or blocked.", + path: "/access/users", + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + blocked: z.boolean(), + monthlyPoints: zodDecimal.nullable(), + unlimited: z.boolean(), + userId: z.number().int(), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Saved", + }, + }, + }, + handler: async c => { + const { userId, ...values } = c.req.valid("json"); + await c + .get("db") + .insert(core_ai_user_overrides) + .values({ userId, ...values }) + .onConflictDoUpdate({ + set: values, + target: core_ai_user_overrides.userId, + }); + + return c.json({ ok: true as const }, 200); + }, +}); + +export const deleteAiUserOverrideAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_manage" }, + route: { + method: "delete", + description: "Remove a user's AI exception; their roles apply again.", + path: "/access/users", + request: { query: z.object({ userId: z.coerce.number().int() }) }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Removed", + }, + }, + }, + handler: async c => { + const { userId } = c.req.valid("query"); + await c + .get("db") + .delete(core_ai_user_overrides) + .where(eq(core_ai_user_overrides.userId, userId)); + + return c.json({ ok: true as const }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/actions.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/actions.route.ts new file mode 100644 index 000000000..23df964d4 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/actions.route.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +import { + DEFAULT_AI_MODEL_CAPABILITIES, + missingCapabilities, +} from "@/api/lib/ai/capabilities"; +import { projectAiAction } from "@/api/lib/ai/registry"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_ai_action_settings } from "@/database/ai"; + +import { zodAiAction } from "../schemas"; + +export const listAiActionsAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_view" }, + route: { + method: "get", + description: + "Every plugin's AI actions with their settings and compatible models.", + path: "/actions", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ actions: z.array(zodAiAction) }), + }, + }, + description: "AI actions", + }, + }, + }, + handler: async c => { + const models = c.get("core").ai?.models ?? []; + const rows = await c.get("db").select().from(core_ai_action_settings); + + return c.json( + { + actions: c + .get("ai") + .actions() + .all() + .map(action => { + const metadata = projectAiAction(action); + const row = rows.find(entry => entry.actionKey === action.key); + + return { + ...metadata, + compatibleModelIds: models + .filter( + entry => + missingCapabilities( + action.definition.requiredCapabilities, + entry.capabilities ?? DEFAULT_AI_MODEL_CAPABILITIES, + ).length === 0, + ) + .map(entry => entry.id), + settings: { + dailyLimit: row?.dailyLimit ?? null, + enabled: row?.enabled ?? true, + fallbackModelId: row?.fallbackModelId ?? null, + instructions: row?.instructions ?? null, + maxInputCharacters: row?.maxInputCharacters ?? null, + maxOutputTokens: row?.maxOutputTokens ?? null, + maxRetries: row?.maxRetries ?? null, + maxSteps: row?.maxSteps ?? null, + modelId: row?.modelId ?? null, + timeoutMs: row?.timeoutMs ?? null, + }, + }; + }), + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/assist.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/assist.route.ts new file mode 100644 index 000000000..605100a24 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/assist.route.ts @@ -0,0 +1,47 @@ +import { + zodAiErrorBody, + zodAiRunBody, + zodAiRunResponse, +} from "@/api/lib/ai/run-schemas"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +/** + * The AdminCP twin of `POST /ai/run`: the same runner, for the AdminCP + * session. There is no separate staff permission on purpose - the action's + * AI permission and its authorize() check (e.g. "may edit posts") decide. + */ +export const assistAiAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Run a registered AI action for the signed-in admin.", + path: "/assist", + request: { + body: { + required: true, + content: { "application/json": { schema: zodAiRunBody } }, + }, + }, + responses: { + 200: { + content: { "application/json": { schema: zodAiRunResponse } }, + description: "The action's validated output and safe usage metadata", + }, + 403: { + content: { "application/json": { schema: zodAiErrorBody } }, + description: "No access to the action or the content", + }, + }, + }, + handler: async c => { + const body = c.req.valid("json"); + const result = await c.get("ai").run({ + ...body, + resource: body.resource ?? undefined, + signal: c.req.raw.signal, + }); + + return c.json(result, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/history.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/history.route.ts new file mode 100644 index 000000000..ad8e3989b --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/history.route.ts @@ -0,0 +1,257 @@ +import { and, asc, eq, gte, lt } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { + withPagination, + zodPaginationPageInfo, + zodPaginationQuery, +} from "@/api/lib/with-pagination"; +import { CONFIG_PLUGIN } from "@/config"; +import { + core_ai_calls, + core_ai_cost_adjustments, + core_ai_runs, +} from "@/database/ai"; +import { core_users } from "@/database/users"; + +import { zodAiRunRow, zodAiRunStatus } from "../schemas"; + +const runColumns = { + accepted: core_ai_runs.accepted, + actionKey: core_ai_runs.actionKey, + actorType: core_ai_runs.actorType, + chargedPoints: core_ai_runs.chargedPoints, + chargedUsd: core_ai_runs.chargedUsd, + costSource: core_ai_runs.costSource, + costUsd: core_ai_runs.costUsd, + createdAt: core_ai_runs.createdAt, + errorCode: core_ai_runs.errorCode, + finishedAt: core_ai_runs.finishedAt, + id: core_ai_runs.id, + inputTokens: core_ai_runs.inputTokens, + modelId: core_ai_runs.modelId, + outputTokens: core_ai_runs.outputTokens, + provider: core_ai_runs.provider, + resourceId: core_ai_runs.resourceId, + resourceType: core_ai_runs.resourceType, + status: core_ai_runs.status, + userId: core_users.id, + userName: core_users.name, + userNameCode: core_users.nameCode, +}; + +const toRow = ({ + userId, + userName, + userNameCode, + ...run +}: Omit, "user"> & { + userId: null | number; + userName: null | string; + userNameCode: null | string; +}) => ({ + ...run, + user: + userId === null + ? null + : { id: userId, name: userName ?? "", nameCode: userNameCode ?? "" }, +}); + +export const listAiHistoryAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_view" }, + route: { + method: "get", + description: + "AI runs, newest first, filtered by date, action, actor, model and status.", + path: "/history", + request: { + query: zodPaginationQuery.extend({ + action: z.string().optional(), + actorType: z.enum(["system", "user"]).optional(), + from: z.coerce.date().optional(), + modelId: z.string().optional(), + order: z.enum(["asc", "desc"]).optional(), + status: zodAiRunStatus.optional(), + to: z.coerce.date().optional(), + userId: z.coerce.number().int().optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + edges: z.array(zodAiRunRow), + pageInfo: zodPaginationPageInfo, + }), + }, + }, + description: "AI runs", + }, + }, + }, + handler: async c => { + const query = c.req.valid("query"); + const filters = and( + query.action ? eq(core_ai_runs.actionKey, query.action) : undefined, + query.actorType ? eq(core_ai_runs.actorType, query.actorType) : undefined, + query.from ? gte(core_ai_runs.createdAt, query.from) : undefined, + query.to ? lt(core_ai_runs.createdAt, query.to) : undefined, + query.modelId ? eq(core_ai_runs.modelId, query.modelId) : undefined, + query.status ? eq(core_ai_runs.status, query.status) : undefined, + query.userId ? eq(core_ai_runs.userId, query.userId) : undefined, + ); + + const data = await withPagination({ + params: { query }, + c, + primaryCursor: core_ai_runs.id, + where: filters, + query: async ({ cursorSelection, limit, offset, where, orderBy }) => + await c + .get("db") + .select({ ...cursorSelection, ...runColumns }) + .from(core_ai_runs) + .leftJoin(core_users, eq(core_users.id, core_ai_runs.userId)) + .where(where) + .orderBy(orderBy) + .limit(limit) + .offset(offset), + table: core_ai_runs, + orderBy: { column: core_ai_runs.createdAt, order: query.order ?? "desc" }, + }); + + return c.json({ ...data, edges: data.edges.map(toRow) }); + }, +}); + +export const showAiRunAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_view" }, + route: { + method: "get", + description: + "One AI run with every provider call and cost adjustment. Prompts and outputs are not stored.", + path: "/history/{id}", + request: { params: z.object({ id: z.coerce.number().int() }) }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + adjustments: z.array( + z.object({ + createdAt: z.date(), + deltaUsd: z.string(), + id: z.number(), + newCostUsd: z.string(), + newSource: z.string(), + previousCostUsd: z.string().nullable(), + previousSource: z.string(), + reason: z.string(), + }), + ), + calls: z.array( + z.object({ + attempt: z.number(), + cacheReadTokens: z.number().nullable(), + cacheWriteTokens: z.number().nullable(), + costReason: z.string().nullable(), + costSource: z.string(), + costUsd: z.string().nullable(), + errorCode: z.string().nullable(), + finishedAt: z.date().nullable(), + id: z.number(), + images: z.number(), + inputTokens: z.number().nullable(), + modelId: z.string(), + outputTokens: z.number().nullable(), + pricingVersion: z.string().nullable(), + provider: z.string(), + providerModelId: z.string().nullable(), + providerRequestId: z.string().nullable(), + reasoningTokens: z.number().nullable(), + startedAt: z.date(), + status: z.string(), + }), + ), + run: zodAiRunRow.extend({ + promptVersion: z.number(), + reservedPoints: z.string(), + reservedUsd: z.string(), + settlement: z.string(), + }), + }), + }, + }, + description: "AI run details", + }, + 404: { description: "No such run" }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const db = c.get("db"); + const [run] = await db + .select({ + ...runColumns, + promptVersion: core_ai_runs.promptVersion, + reservedPoints: core_ai_runs.reservedPoints, + reservedUsd: core_ai_runs.reservedUsd, + settlement: core_ai_runs.settlement, + }) + .from(core_ai_runs) + .leftJoin(core_users, eq(core_users.id, core_ai_runs.userId)) + .where(eq(core_ai_runs.id, id)) + .limit(1); + if (!run) throw new HTTPException(404, { message: "AI run not found" }); + + const [calls, adjustments] = await Promise.all([ + db + .select({ + attempt: core_ai_calls.attempt, + cacheReadTokens: core_ai_calls.cacheReadTokens, + cacheWriteTokens: core_ai_calls.cacheWriteTokens, + costReason: core_ai_calls.costReason, + costSource: core_ai_calls.costSource, + costUsd: core_ai_calls.costUsd, + errorCode: core_ai_calls.errorCode, + finishedAt: core_ai_calls.finishedAt, + id: core_ai_calls.id, + images: core_ai_calls.images, + inputTokens: core_ai_calls.inputTokens, + modelId: core_ai_calls.modelId, + outputTokens: core_ai_calls.outputTokens, + pricingVersion: core_ai_calls.pricingVersion, + provider: core_ai_calls.provider, + providerModelId: core_ai_calls.providerModelId, + providerRequestId: core_ai_calls.providerRequestId, + reasoningTokens: core_ai_calls.reasoningTokens, + startedAt: core_ai_calls.startedAt, + status: core_ai_calls.status, + }) + .from(core_ai_calls) + .where(eq(core_ai_calls.runId, id)) + .orderBy(asc(core_ai_calls.id)), + db + .select({ + createdAt: core_ai_cost_adjustments.createdAt, + deltaUsd: core_ai_cost_adjustments.deltaUsd, + id: core_ai_cost_adjustments.id, + newCostUsd: core_ai_cost_adjustments.newCostUsd, + newSource: core_ai_cost_adjustments.newSource, + previousCostUsd: core_ai_cost_adjustments.previousCostUsd, + previousSource: core_ai_cost_adjustments.previousSource, + reason: core_ai_cost_adjustments.reason, + }) + .from(core_ai_cost_adjustments) + .where(eq(core_ai_cost_adjustments.runId, id)) + .orderBy(asc(core_ai_cost_adjustments.id)), + ]); + + return c.json({ adjustments, calls, run: toRow(run) }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/models.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/models.route.ts new file mode 100644 index 000000000..5753ec316 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/models.route.ts @@ -0,0 +1,87 @@ +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { DEFAULT_AI_MODEL_CAPABILITIES } from "@/api/lib/ai/capabilities"; +import { pricingFingerprint } from "@/api/lib/ai/pricing"; +import { providerIdOf, providerModelIdOf } from "@/api/lib/ai/runner"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_ai_pricing } from "@/database/ai"; + +import { zodAiModel } from "../schemas"; + +/** + * Configured models with their capabilities and the price that applies on + * their connection. Provider objects and keys never leave the server. + */ +export const listAiModelsAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_view" }, + route: { + method: "get", + description: "Registered AI models, capabilities and pricing.", + path: "/models", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ models: z.array(zodAiModel) }), + }, + }, + description: "AI models", + }, + }, + }, + handler: async c => { + const models = c.get("core").ai?.models ?? []; + const rows = await c + .get("db") + .select() + .from(core_ai_pricing) + .where(and(eq(core_ai_pricing.active, true))); + + return c.json( + { + models: models.map(entry => { + const manual = rows.find( + row => row.modelId === entry.id && row.source === "manual", + ); + const sync = rows.find( + row => row.modelId === entry.id && row.source === "sync", + ); + const stored = manual ?? sync; + const catalog = sync?.pricing ?? entry.pricing ?? null; + let pricing: z.infer["pricing"] = null; + if (stored) { + pricing = { + pricing: stored.pricing, + source: stored.source, + updatedAt: stored.createdAt.toISOString(), + version: `${stored.source}:${stored.id}`, + }; + } else if (entry.pricing) { + pricing = { + pricing: entry.pricing, + source: "config", + updatedAt: null, + version: `config:${pricingFingerprint(entry.pricing)}`, + }; + } + + return { + capabilities: [ + ...(entry.capabilities ?? DEFAULT_AI_MODEL_CAPABILITIES), + ], + catalogPricing: manual ? catalog : null, + id: entry.id, + model: providerModelIdOf(entry.model), + name: entry.name, + pricing, + provider: providerIdOf(entry.model), + }; + }), + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/overview.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/overview.route.ts new file mode 100644 index 000000000..3c2771ea8 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/overview.route.ts @@ -0,0 +1,136 @@ +import { z } from "zod"; + +import { loadAiOverview } from "@/api/lib/ai/admin-stats"; +import { + divideByInteger, + formatDecimal, + parseDecimal, +} from "@/api/lib/ai/decimal"; +import { periodContaining } from "@/api/lib/ai/periods"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +const zodBreakdown = z.array( + z.object({ + failures: z.number(), + key: z.string(), + knownCostUsd: z.string(), + knownOperations: z.number(), + operations: z.number(), + }), +); + +export const zodAiOverview = z.object({ + alt: z.object({ + imagesDescribed: z.number(), + knownCostUsd: z.string(), + perImageUsd: z.string().nullable(), + perTranslationUsd: z.string().nullable(), + translations: z.number(), + }), + averageDailyCostUsd: z.string(), + averageOperationCostUsd: z.string().nullable(), + budget: z.object({ + limitUsd: z.string().nullable(), + remainingUsd: z.string().nullable(), + reservedUsd: z.string(), + spentUsd: z.string(), + systemLimitUsd: z.string().nullable(), + systemSpentUsd: z.string(), + }), + byAction: zodBreakdown, + byModel: zodBreakdown, + byOrigin: zodBreakdown, + costSources: z.object({ + estimated: z.number(), + provider: z.number(), + unknown: z.number(), + }), + enabled: z.boolean(), + failureRate: z.number(), + knownCostUsd: z.string(), + operations: z.number(), + period: z.object({ end: z.string(), start: z.string() }), + pricingCoverage: z.number(), + tokens: z.object({ input: z.number(), output: z.number() }), +}); + +export const ALT_GENERATE_ACTION = "@vitnode/core:media.alt.generate"; +export const ALT_TRANSLATE_ACTION = "@vitnode/core:media.alt.translate"; + +export const aiOverviewAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_view" }, + route: { + method: "get", + description: "AI spending and usage for the current or previous month.", + path: "/overview", + request: { + query: z.object({ + period: z.enum(["current", "previous"]).optional(), + }), + }, + responses: { + 200: { + content: { "application/json": { schema: zodAiOverview } }, + description: "AI overview", + }, + }, + }, + handler: async c => { + const { period = "current" } = c.req.valid("query"); + const settings = await c.get("ai").ledger().loadSettings(); + const now = new Date(); + const current = periodContaining(now, "month", settings.timeZone); + const range = + period === "current" + ? current + : periodContaining( + new Date(current.start.getTime() - 1), + "month", + settings.timeZone, + ); + + const overview = await loadAiOverview(c.get("db"), { + globalLimitUsd: settings.monthlyBudgetUsd, + now, + period: range, + systemLimitUsd: settings.systemMonthlyBudgetUsd, + }); + const altGenerate = overview.byAction.find( + row => row.key === ALT_GENERATE_ACTION, + ); + const altTranslate = overview.byAction.find( + row => row.key === ALT_TRANSLATE_ACTION, + ); + const generateCost = parseDecimal(altGenerate?.knownCostUsd ?? "0"); + const translateCost = parseDecimal(altTranslate?.knownCostUsd ?? "0"); + const images = altGenerate?.knownOperations ?? 0; + const translations = altTranslate?.knownOperations ?? 0; + + return c.json( + { + ...overview, + alt: { + imagesDescribed: altGenerate?.operations ?? 0, + knownCostUsd: formatDecimal(generateCost + translateCost), + // Averages of known costs - shown, never charged. One image's cost + // includes its share of every language it was translated into. + perImageUsd: + images > 0 + ? formatDecimal( + divideByInteger(generateCost + translateCost, images), + ) + : null, + perTranslationUsd: + translations > 0 + ? formatDecimal(divideByInteger(translateCost, translations)) + : null, + translations: altTranslate?.operations ?? 0, + }, + enabled: settings.enabled, + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/settings.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/settings.route.ts new file mode 100644 index 000000000..f4786b5a1 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/settings.route.ts @@ -0,0 +1,53 @@ +import { formatDecimal, formatDecimalOrNull } from "@/api/lib/ai/decimal"; +import { + AI_POINTS_CONVERSION, + AI_POINTS_CONVERSION_VERSION, +} from "@/api/lib/ai/ledger"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +import { zodAiSettingsResponse } from "../schemas"; + +export const getAiSettingsAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_view" }, + route: { + method: "get", + description: "Site-wide AI settings: switch, budgets, limits, ALT.", + path: "/settings", + responses: { + 200: { + content: { "application/json": { schema: zodAiSettingsResponse } }, + description: "AI settings", + }, + }, + }, + handler: async c => { + const settings = await c.get("ai").ledger().loadSettings(); + + return c.json( + { + altBatchSize: settings.altBatchSize, + altEnabled: settings.altEnabled, + altLanguages: settings.altLanguages, + defaultMonthlyPoints: formatDecimal(settings.defaultMonthlyPoints), + enabled: settings.enabled, + historyRetentionDays: settings.historyRetentionDays, + monthlyBudgetUsd: formatDecimalOrNull(settings.monthlyBudgetUsd), + pointsConversion: { + usdPerPoint: + AI_POINTS_CONVERSION[AI_POINTS_CONVERSION_VERSION].usdPerPoint, + version: AI_POINTS_CONVERSION_VERSION, + }, + systemConcurrency: settings.systemConcurrency, + systemMonthlyBudgetUsd: formatDecimalOrNull( + settings.systemMonthlyBudgetUsd, + ), + timeZone: settings.timeZone, + userConcurrency: settings.userConcurrency, + userRequestsPerMinute: settings.userRequestsPerMinute, + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/sync-pricing.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/sync-pricing.route.ts new file mode 100644 index 000000000..9bb7ea97b --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/sync-pricing.route.ts @@ -0,0 +1,48 @@ +import { gateway } from "ai"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { syncGatewayPricing } from "@/api/lib/ai/maintenance"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +export const syncAiPricingAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_manage" }, + route: { + method: "post", + description: + "Copy the AI Gateway's published prices onto gateway models. Direct provider connections are never priced from it.", + path: "/models/sync-pricing", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + unchanged: z.array(z.string()), + unpriced: z.array(z.string()), + updated: z.array(z.string()), + }), + }, + }, + description: "Which models changed", + }, + 502: { description: "The gateway catalog could not be read" }, + }, + }, + handler: async c => { + try { + const result = await syncGatewayPricing( + c.get("db"), + c.get("core").ai?.models ?? [], + async () => await gateway.getAvailableModels(), + ); + + return c.json(result, 200); + } catch { + throw new HTTPException(502, { + message: "The AI Gateway price list could not be read.", + }); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/test-action.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/test-action.route.ts new file mode 100644 index 000000000..15b5f69eb --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/test-action.route.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +/** + * Runs an action once with sample input, as the signed-in admin - through the + * same authorization, limits and accounting as any other run. A test is a + * real, billed operation and shows up in history like one. + */ +export const testAiActionAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_manage" }, + route: { + method: "post", + description: "Run an AI action once with sample input.", + path: "/actions/test", + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ input: z.unknown(), key: z.string() }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + output: z.unknown(), + runId: z.number(), + usage: z.object({ + chargedPoints: z.string(), + costKnown: z.boolean(), + modelId: z.string(), + }), + }), + }, + }, + description: "The action's validated output", + }, + }, + }, + handler: async c => { + const { input, key } = c.req.valid("json"); + const result = await c.get("ai").run({ action: key, input }); + + return c.json(result, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/update-action.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/update-action.route.ts new file mode 100644 index 000000000..b68297cab --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/update-action.route.ts @@ -0,0 +1,74 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { + DEFAULT_AI_MODEL_CAPABILITIES, + missingCapabilities, +} from "@/api/lib/ai/capabilities"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_ai_action_settings } from "@/database/ai"; + +import { zodAiActionSettingsInput } from "../schemas"; + +export const updateAiActionAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_manage" }, + route: { + method: "put", + description: + "Configure one AI action: switch, model, fallback, limits and editorial instructions.", + path: "/actions", + request: { + body: { + required: true, + content: { "application/json": { schema: zodAiActionSettingsInput } }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Saved", + }, + 400: { description: "A model lacks a capability the action needs" }, + 404: { description: "Unknown action" }, + }, + }, + handler: async c => { + const { key, ...values } = c.req.valid("json"); + const action = c.get("ai").actions().find(key); + if (!action) { + throw new HTTPException(404, { message: "Unknown AI action" }); + } + + const models = c.get("core").ai?.models ?? []; + for (const modelId of [values.modelId, values.fallbackModelId]) { + if (modelId === null) continue; + const entry = models.find(model => model.id === modelId); + const missing = entry + ? missingCapabilities( + action.definition.requiredCapabilities, + entry.capabilities ?? DEFAULT_AI_MODEL_CAPABILITIES, + ) + : ["configured"]; + if (missing.length > 0) { + throw new HTTPException(400, { + message: `Model "${modelId}" cannot run this action (missing: ${missing.join(", ")}).`, + }); + } + } + + await c + .get("db") + .insert(core_ai_action_settings) + .values({ actionKey: key, ...values }) + .onConflictDoUpdate({ + set: values, + target: core_ai_action_settings.actionKey, + }); + + return c.json({ ok: true as const }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/update-pricing.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/update-pricing.route.ts new file mode 100644 index 000000000..161d0dc18 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/update-pricing.route.ts @@ -0,0 +1,109 @@ +import { and, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { zodAiPricing } from "@/api/lib/ai/pricing"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_ai_pricing } from "@/database/ai"; + +/** + * Sets an admin price override for one model on its connection. It replaces + * the catalog price from now on; earlier calls keep the price they were + * charged at, because each one stored its own snapshot. + */ +export const updateAiPricingAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_manage" }, + route: { + method: "put", + description: "Set a manual price override for a model.", + path: "/models/pricing", + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ modelId: z.string(), pricing: zodAiPricing }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ version: z.string() }) }, + }, + description: "The new pricing version", + }, + 404: { description: "Unknown model" }, + }, + }, + handler: async c => { + const { modelId, pricing } = c.req.valid("json"); + if (!c.get("core").ai?.models.some(entry => entry.id === modelId)) { + throw new HTTPException(404, { message: "Unknown AI model" }); + } + + const id = await c.get("db").transaction(async tx => { + await tx + .update(core_ai_pricing) + .set({ active: false }) + .where( + and( + eq(core_ai_pricing.modelId, modelId), + eq(core_ai_pricing.source, "manual"), + eq(core_ai_pricing.active, true), + ), + ); + const [row] = await tx + .insert(core_ai_pricing) + .values({ + createdById: c.get("admin")?.user.id ?? null, + modelId, + pricing, + source: "manual", + }) + .returning({ id: core_ai_pricing.id }); + + return row.id; + }); + + return c.json({ version: `manual:${id}` }, 200); + }, +}); + +export const deleteAiPricingAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_manage" }, + route: { + method: "delete", + description: "Remove a model's manual price override.", + path: "/models/pricing", + request: { query: z.object({ modelId: z.string() }) }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Override removed; the catalog price applies again", + }, + }, + }, + handler: async c => { + const { modelId } = c.req.valid("query"); + await c + .get("db") + .update(core_ai_pricing) + .set({ active: false }) + .where( + and( + eq(core_ai_pricing.modelId, modelId), + eq(core_ai_pricing.source, "manual"), + eq(core_ai_pricing.active, true), + ), + ); + + return c.json({ ok: true as const }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts new file mode 100644 index 000000000..e14479b92 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts @@ -0,0 +1,48 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { saveAiSettings } from "@/api/lib/ai/settings-store"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +import { zodAiSettings } from "../schemas"; + +export const updateAiSettingsAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_manage" }, + route: { + method: "put", + description: "Update site-wide AI settings.", + path: "/settings", + request: { + body: { + required: true, + content: { "application/json": { schema: zodAiSettings } }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Saved", + }, + 400: { description: "Automatic ALT needs a site budget" }, + }, + }, + handler: async c => { + const body = c.req.valid("json"); + // New paid automation is never unlimited: a background job with no cap + // would spend until somebody noticed. + if (body.altEnabled && body.monthlyBudgetUsd === null) { + throw new HTTPException(400, { + message: + "Set a monthly site budget before turning on automatic ALT text.", + }); + } + + await saveAiSettings(c.get("db"), body); + + return c.json({ ok: true as const }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/schemas.ts b/packages/vitnode/src/api/modules/admin/ai/schemas.ts new file mode 100644 index 000000000..0d474a1e5 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/schemas.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; + +import { AI_ACTION_LIMITS } from "@/api/lib/ai/action"; +import { AI_MODEL_CAPABILITIES } from "@/api/lib/ai/capabilities"; +import { zodAiPricing } from "@/api/lib/ai/pricing"; +import { AI_RUN_STATUSES } from "@/database/ai"; + +/** USD or points as a decimal string - money never travels as a float. */ +export const zodDecimalString = z + .string() + .regex(/^\d+(\.\d{1,12})?$/, 'Use a non-negative decimal, e.g. "25.00".'); + +const limit = (field: keyof typeof AI_ACTION_LIMITS) => + z + .number() + .int() + .min(AI_ACTION_LIMITS[field].min) + .max(AI_ACTION_LIMITS[field].max); + +export const zodAiSettings = z.object({ + altBatchSize: z.number().int().min(1).max(100), + altEnabled: z.boolean(), + altLanguages: z.array(z.string().min(2).max(32)).nullable(), + defaultMonthlyPoints: zodDecimalString, + enabled: z.boolean(), + historyRetentionDays: z.number().int().min(7).max(3650), + monthlyBudgetUsd: zodDecimalString.nullable(), + systemConcurrency: z.number().int().min(1).max(20), + systemMonthlyBudgetUsd: zodDecimalString.nullable(), + userConcurrency: z.number().int().min(1).max(20), + userRequestsPerMinute: z.number().int().min(1).max(600), +}); + +export const zodAiSettingsResponse = zodAiSettings.extend({ + pointsConversion: z.object({ usdPerPoint: z.string(), version: z.number() }), + timeZone: z.string(), +}); + +export const zodAiCapability = z.enum(AI_MODEL_CAPABILITIES); + +export const zodAiModel = z.object({ + capabilities: z.array(zodAiCapability), + id: z.string(), + model: z.string(), + name: z.string(), + pricing: z + .object({ + pricing: zodAiPricing, + source: z.enum(["config", "manual", "sync"]), + updatedAt: z.string().nullable(), + version: z.string(), + }) + .nullable(), + /** The catalog price under an admin override, when there is one. */ + catalogPricing: zodAiPricing.nullable(), + provider: z.string(), +}); + +export const zodAiActionSettingsInput = z.object({ + dailyLimit: limit("dailyLimit").nullable(), + enabled: z.boolean(), + fallbackModelId: z.string().max(100).nullable(), + instructions: z.string().max(2_000).nullable(), + key: z.string().min(3).max(255), + maxInputCharacters: limit("maxInputCharacters").nullable(), + maxOutputTokens: limit("maxOutputTokens").nullable(), + maxRetries: limit("maxRetries").nullable(), + maxSteps: limit("maxSteps").nullable(), + modelId: z.string().max(100).nullable(), + timeoutMs: limit("timeoutMs").nullable(), +}); + +export const zodAiAction = z.object({ + actors: z.array(z.enum(["system", "user"])), + compatibleModelIds: z.array(z.string()), + defaults: z.object({ + dailyLimit: z.number().nullable(), + maxImages: z.number(), + maxInputCharacters: z.number(), + maxOutputTokens: z.number(), + maxRetries: z.number(), + maxSteps: z.number(), + timeoutMs: z.number(), + }), + description: z.string().nullable(), + key: z.string(), + localId: z.string(), + output: z.enum(["object", "text"]), + permission: z.object({ defaultGranted: z.boolean(), key: z.string() }), + pluginId: z.string(), + promptVersion: z.number(), + requiredCapabilities: z.array(zodAiCapability), + settings: zodAiActionSettingsInput.omit({ key: true }), +}); + +export const zodAiRunStatus = z.enum(AI_RUN_STATUSES); + +export const zodAiRunRow = z.object({ + accepted: z.boolean().nullable(), + actionKey: z.string(), + actorType: z.enum(["system", "user"]), + chargedPoints: z.string().nullable(), + chargedUsd: z.string().nullable(), + costSource: z.string().nullable(), + costUsd: z.string().nullable(), + createdAt: z.date(), + errorCode: z.string().nullable(), + finishedAt: z.date().nullable(), + id: z.number(), + inputTokens: z.number().nullable(), + modelId: z.string().nullable(), + outputTokens: z.number().nullable(), + provider: z.string().nullable(), + resourceId: z.string().nullable(), + resourceType: z.string().nullable(), + status: zodAiRunStatus, + user: z + .object({ id: z.number(), name: z.string(), nameCode: z.string() }) + .nullable(), +}); diff --git a/packages/vitnode/src/api/modules/ai/ai-routes.integration.test.ts b/packages/vitnode/src/api/modules/ai/ai-routes.integration.test.ts new file mode 100644 index 000000000..a5100fe9c --- /dev/null +++ b/packages/vitnode/src/api/modules/ai/ai-routes.integration.test.ts @@ -0,0 +1,588 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { MockLanguageModelV4 } from "ai/test"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, expect, it } from "vitest"; +import { z } from "zod"; + +import type { AIModelDefinition } from "@/api/models/ai"; +import type { TestDatabaseHandle } from "@/tests/postgres"; + +import { defineAiAction } from "@/api/lib/ai/action"; +import { parseDecimal } from "@/api/lib/ai/decimal"; +import { + applyAiCostAdjustment, + expireAiLeases, + syncGatewayPricing, +} from "@/api/lib/ai/maintenance"; +import { PostgresAiLedger } from "@/api/lib/ai/postgres-ledger"; +import { collectAiActions } from "@/api/lib/ai/registry"; +import { AIModel } from "@/api/models/ai"; +import * as adminsTables from "@/database/admins"; +import * as aiTables from "@/database/ai"; +import { + core_ai_budget_periods, + core_ai_calls, + core_ai_pricing, + core_ai_runs, +} from "@/database/ai"; +import * as filesTables from "@/database/files"; +import * as languagesTables from "@/database/languages"; +import { core_languages, core_languages_words } from "@/database/languages"; +import * as moderatorsTables from "@/database/moderators"; +import * as rolesTables from "@/database/roles"; +import { core_roles } from "@/database/roles"; +import * as sessionsTables from "@/database/sessions"; +import * as usersTables from "@/database/users"; +import { core_users } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createTestDatabase, describePostgres } from "@/tests/postgres"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { aiAdminModule } from "../admin/ai/ai.admin.module"; +import { aiModule } from "./ai.module"; + +const ADMIN_ID = 1; +const ALICE = 2; +const BOB = 3; + +const summarize = defineAiAction({ + authorize: () => true, + buildPrompt: input => ({ prompt: input.text }), + defaults: { + maxInputCharacters: 1_000, + maxOutputTokens: 50, + timeoutMs: 10_000, + }, + id: "summary.generate", + inputSchema: z.object({ text: z.string() }), + output: "text", + outputSchema: z.string().min(1), + parseText: text => text.trim(), + permission: { defaultGranted: true, key: "summary" }, + promptVersion: 1, + requiredCapabilities: ["text"], +}); + +const aiActions = collectAiActions([ + { aiActions: [summarize], pluginId: "@acme/notes" }, +]); + +const answering = () => + new MockLanguageModelV4({ + doGenerate: async () => + Promise.resolve({ + content: [{ text: "Short.", type: "text" }], + finishReason: { raw: "stop", unified: "stop" }, + usage: { + inputTokens: { cacheRead: 0, cacheWrite: 0, noCache: 10, total: 10 }, + outputTokens: { reasoning: 0, text: 5, total: 5 }, + }, + warnings: [], + }), + }); + +describePostgres("AI routes (real PostgreSQL)", () => { + let database: TestDatabaseHandle; + const cache = createTestCache(); + let models: AIModelDefinition[]; + + const app = () => { + const hono = new OpenAPIHono(); + hono.use("*", async (c, next) => { + const userId = Number(c.req.header("x-user") ?? 0) || null; + const isAdmin = c.req.path.includes("/admin/"); + c.set("db", database.db); + c.set("cache", cache); + c.set("core" as never, { ai: { models }, aiActions } as never); + const identity = userId + ? { id: userId, roleId: userId === ADMIN_ID ? 1 : 2 } + : null; + c.set("user", (identity && !isAdmin ? identity : null) as never); + c.set( + "admin", + (identity && isAdmin ? { user: identity } : null) as never, + ); + c.set("ai", new AIModel(c as unknown as Context)); + await next(); + }); + hono.route("/ai", aiModule.hono); + hono.route("/admin/ai", aiAdminModule.hono); + + return hono; + }; + + const request = async ( + path: string, + { + body, + method = "GET", + user, + }: { body?: unknown; method?: string; user: number }, + ) => + await app().request(path, { + body: body === undefined ? undefined : JSON.stringify(body), + headers: { "Content-Type": "application/json", "x-user": String(user) }, + method, + }); + + const runFor = async (user: number) => + await request("/ai/run", { + body: { action: "@acme/notes:summary.generate", input: { text: "x" } }, + method: "POST", + user, + }); + + beforeAll(async () => { + database = await createTestDatabase({ + ...adminsTables, + ...moderatorsTables, + ...sessionsTables, + ...aiTables, + ...filesTables, + ...languagesTables, + ...rolesTables, + ...usersTables, + }); + models = [ + { + id: "default", + model: answering(), + name: "Test", + pricing: { + rates: { inputPerMillion: "1000", outputPerMillion: "2000" }, + }, + }, + ]; + await database.db + .insert(core_languages) + .values({ code: "en", default: true, name: "English" }); + await database.db.insert(core_roles).values([ + { id: 1, root: true, updatedAt: new Date() }, + { id: 2, updatedAt: new Date() }, + ]); + await database.db.insert(core_languages_words).values({ + itemId: 2, + languageCode: "en", + pluginCode: "core", + tableName: "core_roles", + value: "Member", + variable: "name", + }); + await database.db.insert(core_users).values( + [ADMIN_ID, ALICE, BOB].map(id => ({ + avatarColor: "000000", + email: `u${id}@test.test`, + id, + ipAddress: "127.0.0.1", + name: `u${id}`, + nameCode: `u${id}`, + roleId: id === ADMIN_ID ? 1 : 2, + })), + ); + await database.db.insert(aiTables.core_ai_role_policies).values({ + monthlyPoints: "1000", + roleId: 2, + }); + await grantStaffPermissions(cache, { + permissions: [ + { module: "ai", permission: "can_view", plugin: "@vitnode/core" }, + ], + userId: ADMIN_ID, + }); + }, 60_000); + + afterAll(async () => { + await database?.drop(); + }); + + it("runs an action for the signed-in user and returns safe usage", async () => { + const response = await runFor(ALICE); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + output: "Short.", + usage: { chargedPoints: "20", costKnown: true, modelId: "default" }, + }); + }); + + it("refuses anonymous runs", async () => { + const response = await runFor(0); + + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ code: "AI_UNAUTHORIZED" }); + }); + + it("shows each user only their own history and usage", async () => { + await runFor(BOB); + await runFor(BOB); + + const alice = (await ( + await request("/ai/history", { user: ALICE }) + ).json()) as { + items: { id: number }[]; + }; + const bob = (await ( + await request("/ai/history", { user: BOB }) + ).json()) as { + items: { id: number }[]; + }; + const aliceRunIds = ( + await database.db + .select({ id: core_ai_runs.id }) + .from(core_ai_runs) + .where(eq(core_ai_runs.userId, ALICE)) + ).map(row => row.id); + + expect(alice.items.map(item => item.id).sort()).toEqual(aliceRunIds.sort()); + expect(bob.items).toHaveLength(2); + // A cursor from someone else's history widens nothing. + const sneaky = (await ( + await request(`/ai/history?before=2147483647`, { + user: ALICE, + }) + ).json()) as { items: { id: number }[] }; + expect(sneaky.items.map(item => item.id).sort()).toEqual( + aliceRunIds.sort(), + ); + + const usage = (await ( + await request("/ai/usage", { user: BOB }) + ).json()) as { + points: { total: string; used: string }; + }; + expect(usage.points).toMatchObject({ total: "1000", used: "40" }); + }); + + it("only lets a user give feedback on their own run", async () => { + const [bobsRun] = await database.db + .select({ id: core_ai_runs.id }) + .from(core_ai_runs) + .where(eq(core_ai_runs.userId, BOB)) + .limit(1); + + const foreign = await request(`/ai/runs/${bobsRun.id}/feedback`, { + body: { accepted: true }, + method: "POST", + user: ALICE, + }); + const own = await request(`/ai/runs/${bobsRun.id}/feedback`, { + body: { accepted: true }, + method: "POST", + user: BOB, + }); + + expect(foreign.status).toBe(404); + expect(own.status).toBe(200); + }); + + it("keeps the AdminCP behind staff permissions", async () => { + const withView = await request("/admin/ai/history", { user: ADMIN_ID }); + const without = await request("/admin/ai/history", { user: ALICE }); + const manage = await request("/admin/ai/settings", { + body: { + altBatchSize: 10, + altEnabled: false, + altLanguages: null, + defaultMonthlyPoints: "0", + enabled: true, + historyRetentionDays: 365, + monthlyBudgetUsd: null, + systemConcurrency: 2, + systemMonthlyBudgetUsd: null, + userConcurrency: 2, + userRequestsPerMinute: 10, + }, + method: "PUT", + user: ADMIN_ID, + }); + + expect(withView.status).toBe(200); + expect(without.status).toBe(403); + // can_view does not include can_manage. + expect(manage.status).toBe(403); + }); + + it("refuses to switch on automatic ALT without a site budget", async () => { + await grantStaffPermissions(cache, { + permissions: [ + { module: "ai", permission: "can_view", plugin: "@vitnode/core" }, + { module: "ai", permission: "can_manage", plugin: "@vitnode/core" }, + ], + userId: ADMIN_ID, + }); + const settings = { + altBatchSize: 10, + altEnabled: true, + altLanguages: null, + defaultMonthlyPoints: "0", + enabled: true, + historyRetentionDays: 365, + monthlyBudgetUsd: null, + systemConcurrency: 2, + systemMonthlyBudgetUsd: null, + userConcurrency: 2, + userRequestsPerMinute: 10, + }; + + const refused = await request("/admin/ai/settings", { + body: settings, + method: "PUT", + user: ADMIN_ID, + }); + const saved = await request("/admin/ai/settings", { + body: { ...settings, monthlyBudgetUsd: "25" }, + method: "PUT", + user: ADMIN_ID, + }); + + expect(refused.status).toBe(400); + expect(saved.status).toBe(200); + expect( + (await ( + await request("/admin/ai/settings", { user: ADMIN_ID }) + ).json()) as object, + ).toMatchObject({ altEnabled: true, monthlyBudgetUsd: "25" }); + }); + + it("reports known cost, coverage and averages without counting unknown as zero", async () => { + // One more run, on a model with no pricing: its cost is unknown. That is + // only allowed with no cap in the way, so the site budget goes too. + models = [{ ...models[0], pricing: undefined }]; + await database.db + .update(aiTables.core_ai_settings) + .set({ altEnabled: false, monthlyBudgetUsd: null }); + await database.db + .update(aiTables.core_ai_role_policies) + .set({ unlimited: true }) + .where(eq(aiTables.core_ai_role_policies.roleId, 2)); + await runFor(ALICE); + + const overview = (await ( + await request("/admin/ai/overview", { user: ADMIN_ID }) + ).json()) as { + averageOperationCostUsd: string; + costSources: { unknown: number }; + operations: number; + pricingCoverage: number; + }; + + expect(overview.operations).toBe(4); + expect(overview.costSources.unknown).toBe(1); + expect(overview.pricingCoverage).toBe(0.75); + // 3 known runs at $0.02 each: the unknown one is left out, not averaged as $0. + expect(overview.averageOperationCostUsd).toBe("0.02"); + }); +}); + +describePostgres("AI maintenance (real PostgreSQL)", () => { + let database: TestDatabaseHandle; + let ledger: PostgresAiLedger; + + const reserve = async (userId: null | number, now = new Date()) => { + const result = await ledger.reserve({ + actionKey: "@acme/notes:summary.generate", + actorType: userId === null ? "system" : "user", + daily: null, + globalMonthlyUsd: null, + idempotencyKey: null, + leaseMs: 1_000, + model: { + modelId: "default", + provider: "gateway", + providerModelId: "x/y", + }, + now, + pluginId: "@acme/notes", + promptVersion: 1, + resource: undefined, + sourceFingerprint: null, + systemConcurrency: 10, + systemMonthlyUsd: null, + timeZone: "UTC", + usd: parseDecimal("0.05"), + userConcurrency: 10, + userId, + userMonthlyPoints: null, + userRequestsPerMinute: 100, + }); + if (!result.ok) throw new Error(result.code); + + return result.runId; + }; + + const spent = async (scopeKey: string) => { + const [row] = await database.db + .select({ spent: core_ai_budget_periods.spentAmount }) + .from(core_ai_budget_periods) + .where(eq(core_ai_budget_periods.scopeKey, scopeKey)); + + return row?.spent; + }; + + beforeAll(async () => { + database = await createTestDatabase({ + ...adminsTables, + ...moderatorsTables, + ...sessionsTables, + ...aiTables, + ...filesTables, + ...languagesTables, + ...rolesTables, + ...usersTables, + }); + ledger = new PostgresAiLedger(database.db); + await database.db + .insert(core_languages) + .values({ code: "en", default: true, name: "English" }); + await database.db + .insert(core_roles) + .values({ id: 1, updatedAt: new Date() }); + await database.db.insert(core_users).values({ + avatarColor: "000000", + email: "u@test.test", + id: 1, + ipAddress: "127.0.0.1", + name: "u", + nameCode: "u", + roleId: 1, + }); + }, 60_000); + + afterAll(async () => { + await database?.drop(); + }); + + it("settles a run whose process died as uncertain, charging the site but not the user", async () => { + const runId = await reserve(1, new Date(Date.now() - 10_000)); + await ledger.markRunning(runId, new Date(Date.now() - 5_000)); + // The provider call started and never reported back. + await ledger.beginCall({ + attempt: 1, + modelId: "default", + provider: "gateway", + providerModelId: "x/y", + runId, + startedAt: new Date(Date.now() - 9_000), + }); + + expect(await expireAiLeases(database.db)).toBe(1); + const [run] = await database.db + .select() + .from(core_ai_runs) + .where(eq(core_ai_runs.id, runId)); + expect(run).toMatchObject({ + chargedPoints: "0.000000000000", + chargedUsd: "0.050000000000", + settlement: "settled", + status: "uncertain", + }); + expect(await expireAiLeases(database.db)).toBe(0); + }); + + it("replaces an estimate with the billed cost once, moving budgets by the difference", async () => { + const runId = await reserve(null); + const callId = await ledger.beginCall({ + attempt: 1, + modelId: "default", + provider: "gateway", + providerModelId: "x/y", + runId, + startedAt: new Date(), + }); + await ledger.finishCall(callId, { + cost: { + amountUsd: "0.01", + pricingVersion: "config:1", + source: "pricing", + }, + errorCode: null, + finishedAt: new Date(), + images: 0, + pricingSnapshot: null, + providerModelId: "x/y", + providerRequestId: "gen_1", + status: "succeeded", + usage: { + cacheReadTokens: 0, + cacheWriteTokens: 0, + inputTokens: 10, + outputTokens: 5, + reasoningTokens: null, + }, + }); + await ledger.settle(runId, { + delivered: true, + errorCode: null, + finishedAt: new Date(), + status: "succeeded", + }); + const before = parseDecimal((await spent("system")) ?? "0"); + + const first = await applyAiCostAdjustment(database.db, { + billedUsd: parseDecimal("0.012"), + callId, + reason: "gateway", + }); + const second = await applyAiCostAdjustment(database.db, { + billedUsd: parseDecimal("0.012"), + callId, + reason: "gateway", + }); + + expect([first, second]).toEqual([true, false]); + expect(parseDecimal((await spent("system")) ?? "0") - before).toBe( + parseDecimal("0.002"), + ); + const [call] = await database.db + .select({ + costSource: core_ai_calls.costSource, + costUsd: core_ai_calls.costUsd, + }) + .from(core_ai_calls) + .where(eq(core_ai_calls.id, callId)); + expect(call).toEqual({ costSource: "provider", costUsd: "0.012000000000" }); + }); + + it("versions synced gateway prices and leaves direct connections alone", async () => { + const catalog = (input: string) => async () => + await Promise.resolve({ + models: [ + { id: "anthropic/claude-x", pricing: { input, output: "0.000015" } }, + ], + }); + const models: AIModelDefinition[] = [ + { id: "gw", model: "anthropic/claude-x", name: "Gateway" }, + { id: "direct", model: new MockLanguageModelV4(), name: "Direct" }, + ]; + + const first = await syncGatewayPricing( + database.db, + models, + catalog("0.000003"), + ); + const again = await syncGatewayPricing( + database.db, + models, + catalog("0.000003"), + ); + const changed = await syncGatewayPricing( + database.db, + models, + catalog("0.000004"), + ); + + expect(first.updated).toEqual(["gw"]); + expect(again.unchanged).toEqual(["gw"]); + expect(changed.updated).toEqual(["gw"]); + const rows = await database.db.select().from(core_ai_pricing); + expect(rows.filter(row => row.modelId === "gw")).toHaveLength(2); + expect(rows.filter(row => row.active)).toHaveLength(1); + expect(rows.find(row => row.active)?.pricing.rates.inputPerMillion).toBe( + "4", + ); + expect(rows.some(row => row.modelId === "direct")).toBe(false); + }); +}); diff --git a/packages/vitnode/src/api/modules/ai/ai.module.ts b/packages/vitnode/src/api/modules/ai/ai.module.ts new file mode 100644 index 000000000..5f71647d8 --- /dev/null +++ b/packages/vitnode/src/api/modules/ai/ai.module.ts @@ -0,0 +1,15 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; + +import { aiMaintenanceCron } from "./cron/maintenance.cron"; +import { aiFeedbackRoute } from "./routes/feedback.route"; +import { aiHistoryRoute } from "./routes/history.route"; +import { runAiRoute } from "./routes/run.route"; +import { aiUsageRoute } from "./routes/usage.route"; + +export const aiModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "ai", + routes: [aiUsageRoute, aiHistoryRoute, aiFeedbackRoute, runAiRoute], + cronJobs: [aiMaintenanceCron], +}); diff --git a/packages/vitnode/src/api/modules/ai/cron/maintenance.cron.ts b/packages/vitnode/src/api/modules/ai/cron/maintenance.cron.ts new file mode 100644 index 000000000..464c6eba6 --- /dev/null +++ b/packages/vitnode/src/api/modules/ai/cron/maintenance.cron.ts @@ -0,0 +1,40 @@ +import { gateway } from "ai"; + +import { decimalFromNumber } from "@/api/lib/ai/decimal"; +import { + expireAiLeases, + pruneAiHistory, + reconcileAiCosts, +} from "@/api/lib/ai/maintenance"; +import { gatewayProviderAdapter } from "@/api/lib/ai/usage-cost"; +import { buildCron } from "@/api/lib/cron"; + +/** Looks a gateway generation's billed cost up after the fact. */ +const gatewayWithLookup = gatewayProviderAdapter(async id => { + const info = await gateway.getGenerationInfo({ id }); + + return decimalFromNumber(info.totalCost); +}); + +export const aiMaintenanceCron = buildCron({ + name: "ai-maintenance", + description: + "Settle AI runs whose process died, reconcile estimated costs with billed ones, and prune old history", + schedule: "*/10 * * * *", + handler: async c => { + const db = c.get("db"); + await expireAiLeases(db); + + if ( + c.get("core").ai?.models.some(entry => typeof entry.model === "string") + ) { + await reconcileAiCosts(db, [ + ...(c.get("core").ai?.providerAdapters ?? []), + gatewayWithLookup, + ]); + } + + const settings = await c.get("ai").ledger().loadSettings(); + await pruneAiHistory(db, settings.historyRetentionDays); + }, +}); diff --git a/packages/vitnode/src/api/modules/ai/routes/feedback.route.ts b/packages/vitnode/src/api/modules/ai/routes/feedback.route.ts new file mode 100644 index 000000000..7b5c97eec --- /dev/null +++ b/packages/vitnode/src/api/modules/ai/routes/feedback.route.ts @@ -0,0 +1,62 @@ +import { and, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_ai_runs } from "@/database/ai"; + +/** + * Records whether a suggestion was accepted. Tracking only - billing already + * happened when the suggestion was delivered. + */ +export const aiFeedbackRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Record whether you accepted an AI suggestion.", + path: "/runs/{id}/feedback", + request: { + params: z.object({ id: z.coerce.number().int() }), + body: { + required: true, + content: { + "application/json": { schema: z.object({ accepted: z.boolean() }) }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Recorded", + }, + 404: { description: "Not one of your runs" }, + }, + }, + handler: async c => { + const userId = c.get("admin")?.user.id ?? c.get("user")?.id; + if (!userId) throw new HTTPException(401); + const { id } = c.req.valid("param"); + const { accepted } = c.req.valid("json"); + + const updated = await c + .get("db") + .update(core_ai_runs) + .set({ accepted, acceptedAt: new Date() }) + .where( + and( + eq(core_ai_runs.id, id), + eq(core_ai_runs.userId, userId), + eq(core_ai_runs.status, "succeeded"), + ), + ) + .returning({ id: core_ai_runs.id }); + if (updated.length === 0) { + throw new HTTPException(404, { message: "AI run not found" }); + } + + return c.json({ ok: true as const }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/ai/routes/history.route.ts b/packages/vitnode/src/api/modules/ai/routes/history.route.ts new file mode 100644 index 000000000..efe3cc594 --- /dev/null +++ b/packages/vitnode/src/api/modules/ai/routes/history.route.ts @@ -0,0 +1,93 @@ +import { and, desc, eq, lt } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_ai_runs } from "@/database/ai"; + +const PAGE_SIZE = 20; + +/** + * The signed-in user's own recent AI operations. The user id comes from the + * session; no query parameter can widen it. + */ +export const aiHistoryRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: "Your recent AI operations, newest first.", + path: "/history", + request: { + query: z.object({ + before: z.coerce + .number() + .int() + .positive() + .max(2_147_483_647) + .optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + items: z.array( + z.object({ + accepted: z.boolean().nullable(), + actionKey: z.string(), + chargedPoints: z.string().nullable(), + createdAt: z.date(), + errorCode: z.string().nullable(), + id: z.number(), + status: z.string(), + }), + ), + nextBefore: z.number().nullable(), + }), + }, + }, + description: "Your AI history", + }, + 401: { description: "Not signed in" }, + }, + }, + handler: async c => { + const user = c.get("user"); + if (!user) throw new HTTPException(401); + const { before } = c.req.valid("query"); + + const rows = await c + .get("db") + .select({ + accepted: core_ai_runs.accepted, + actionKey: core_ai_runs.actionKey, + chargedPoints: core_ai_runs.chargedPoints, + createdAt: core_ai_runs.createdAt, + errorCode: core_ai_runs.errorCode, + id: core_ai_runs.id, + status: core_ai_runs.status, + }) + .from(core_ai_runs) + .where( + and( + eq(core_ai_runs.actorType, "user"), + eq(core_ai_runs.userId, user.id), + before ? lt(core_ai_runs.id, before) : undefined, + ), + ) + .orderBy(desc(core_ai_runs.id)) + .limit(PAGE_SIZE + 1); + + const items = rows.slice(0, PAGE_SIZE); + + return c.json( + { + items, + nextBefore: rows.length > PAGE_SIZE ? (items.at(-1)?.id ?? null) : null, + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/ai/routes/run.route.ts b/packages/vitnode/src/api/modules/ai/routes/run.route.ts new file mode 100644 index 000000000..820e1308a --- /dev/null +++ b/packages/vitnode/src/api/modules/ai/routes/run.route.ts @@ -0,0 +1,51 @@ +import { + zodAiErrorBody, + zodAiRunBody, + zodAiRunResponse, +} from "@/api/lib/ai/run-schemas"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +/** + * Runs any registered user action for the signed-in user. Safe as a generic + * endpoint because every user action must declare authorize(), and the + * runner checks the AI permission, limits and budgets before any call. + */ +export const runAiRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Run a registered AI action for the signed-in user.", + path: "/run", + request: { + body: { + required: true, + content: { "application/json": { schema: zodAiRunBody } }, + }, + }, + responses: { + 200: { + content: { "application/json": { schema: zodAiRunResponse } }, + description: "The action's validated output and safe usage metadata", + }, + 403: { + content: { "application/json": { schema: zodAiErrorBody } }, + description: "No access to the action or the content", + }, + 429: { + content: { "application/json": { schema: zodAiErrorBody } }, + description: "A personal limit was reached", + }, + }, + }, + handler: async c => { + const body = c.req.valid("json"); + const result = await c.get("ai").run({ + ...body, + resource: body.resource ?? undefined, + signal: c.req.raw.signal, + }); + + return c.json(result, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/ai/routes/usage.route.ts b/packages/vitnode/src/api/modules/ai/routes/usage.route.ts new file mode 100644 index 000000000..6d36010ef --- /dev/null +++ b/packages/vitnode/src/api/modules/ai/routes/usage.route.ts @@ -0,0 +1,52 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { loadUserAiUsage } from "@/api/lib/ai/usage-summary"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +export const zodAiUserUsage = z.object({ + actions: z.array( + z.object({ + dailyLimit: z.number().nullable(), + description: z.string().nullable(), + key: z.string(), + permissionKey: z.string(), + usedToday: z.number(), + }), + ), + enabled: z.boolean(), + notice: z.enum(["exhausted", "near_limit", "none", "site_paused"]), + points: z.object({ + available: z.string().nullable(), + reserved: z.string(), + total: z.string().nullable(), + used: z.string(), + }), + resetsAt: z.string(), + sitePaused: z.boolean(), +}); + +/** The signed-in user's own allowance - there is no parameter for anybody else. */ +export const aiUsageRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: + "Your AI points, reservations, reset date and the AI features you can use.", + path: "/usage", + responses: { + 200: { + content: { "application/json": { schema: zodAiUserUsage } }, + description: "Your AI usage", + }, + 401: { description: "Not signed in" }, + }, + }, + handler: async c => { + const user = c.get("user"); + if (!user) throw new HTTPException(401); + + return c.json(await loadUserAiUsage(c, c.get("ai").ledger(), user.id), 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/middleware/route.ts b/packages/vitnode/src/api/modules/middleware/route.ts index bcd7ceb76..3544bb77c 100644 --- a/packages/vitnode/src/api/modules/middleware/route.ts +++ b/packages/vitnode/src/api/modules/middleware/route.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { AI_MODEL_CAPABILITIES } from "@/api/lib/ai/capabilities"; import { buildRoute } from "@/api/lib/route"; import { loadPublicNavigationMenus } from "@/api/modules/admin/navigation/lib/cache"; import { @@ -12,9 +13,11 @@ export const routeMiddlewareSchema = z.object({ ai: z.object({ models: z.array( z.object({ + capabilities: z.array(z.enum(AI_MODEL_CAPABILITIES)), id: z.string(), model: z.string(), name: z.string(), + provider: z.string(), }), ), }), diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index 98ecdf011..074212578 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -6,6 +6,7 @@ import { settingsPage } from "@/views/auth/settings/widgets/settings-page"; import { buildApiPlugin } from "./lib/plugin"; import { adminModule } from "./modules/admin/admin.module"; +import { aiModule } from "./modules/ai/ai.module"; import { contentModule } from "./modules/content/content.module"; import { cronModule } from "./modules/cron/cron.module"; import { middlewareModule } from "./modules/middleware/middleware.module"; @@ -28,6 +29,7 @@ export const newBuildPluginApiCore = buildApiPlugin({ usersModule, notificationsModule, adminModule, + aiModule, contentModule, cronModule, pagesModule, @@ -85,6 +87,7 @@ export const newBuildPluginApiCore = buildApiPlugin({ { permission: "can_edit", dependsOn: ["can_view"] }, { permission: "can_manage", dependsOn: ["can_view"] }, ], + ai: ["can_view", { permission: "can_manage", dependsOn: ["can_view"] }], cron: ["can_view", { permission: "can_run", dependsOn: ["can_view"] }], staff_moderators: [ "can_view", diff --git a/packages/vitnode/src/lib/ai/format-points.test.ts b/packages/vitnode/src/lib/ai/format-points.test.ts new file mode 100644 index 000000000..9b398bb2b --- /dev/null +++ b/packages/vitnode/src/lib/ai/format-points.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { formatAiPoints, formatAiUsd } from "./format-points"; + +describe("formatAiPoints", () => { + it("shows at most one decimal place", () => { + expect(formatAiPoints("12")).toBe("12"); + expect(formatAiPoints("0.3")).toBe("0.3"); + expect(formatAiPoints("32.85")).toBe("32.8"); + }); + + it("never shows a positive amount as zero", () => { + expect(formatAiPoints("0.0001")).toBe("<0.1"); + expect(formatAiPoints("0")).toBe("0"); + }); + + it("shows an unlimited allowance as infinity", () => { + expect(formatAiPoints(null)).toBe("∞"); + }); +}); + +describe("formatAiUsd", () => { + it("keeps sub-cent costs readable", () => { + expect(formatAiUsd("0.0003")).toBe("$0.0003"); + expect(formatAiUsd("25")).toBe("$25.00"); + expect(formatAiUsd(null)).toBe("—"); + }); +}); diff --git a/packages/vitnode/src/lib/ai/format-points.ts b/packages/vitnode/src/lib/ai/format-points.ts new file mode 100644 index 000000000..df88d2031 --- /dev/null +++ b/packages/vitnode/src/lib/ai/format-points.ts @@ -0,0 +1,34 @@ +/** + * AI points for display: at most one decimal place, and `<0.1` for any + * positive amount too small to show - never a misleading `0`. Points are + * kept at full precision everywhere else; this is presentation only. + */ +export const formatAiPoints = ( + points: null | string, + locale = "en", +): string => { + if (points === null) return "∞"; + const value = Number(points); + if (!Number.isFinite(value)) return points; + if (value > 0 && value < 0.1) { + return `<${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(0.1)}`; + } + + return new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format( + Math.floor(value * 10) / 10, + ); +}; + +/** USD for the AdminCP: small amounts keep enough digits to be meaningful. */ +export const formatAiUsd = (usd: null | string, locale = "en"): string => { + if (usd === null) return "—"; + const value = Number(usd); + if (!Number.isFinite(value)) return usd; + + return new Intl.NumberFormat(locale, { + currency: "USD", + maximumFractionDigits: value !== 0 && Math.abs(value) < 0.01 ? 6 : 2, + minimumFractionDigits: 2, + style: "currency", + }).format(value); +}; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 922b5eec0..48aa8bf4f 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -34,6 +34,9 @@ "@vitnode/core:navigation:can_create": "Add main menu items", "@vitnode/core:navigation:can_edit": "Edit and reorder main menu items", "@vitnode/core:navigation:can_delete": "Remove main menu items", + "@vitnode/core:ai": "AI", + "@vitnode/core:ai:can_view": "View AI usage, settings and history", + "@vitnode/core:ai:can_manage": "Manage AI settings, models, pricing and access", "@vitnode/core:queue": "Queue Tasks", "@vitnode/core:queue:can_view": "View queue tasks", "@vitnode/core:notifications": "Notifications", From 8678e489878255f9a69c778b58cdac9225b21faa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:30:09 +0000 Subject: [PATCH 03/14] feat(ai): multilingual ALT text for core_files with automatic generation Images in core_files get per-language default ALT (core_files_alt) with human/AI origin, a file fingerprint, and an automatic/manual/disabled policy. A system action describes each image once with a vision model and translates the base description into missing site languages; writes are conditional and never overwrite human text. Uploads enqueue work; an hourly bounded sweep repairs misses and new languages. The queue gains durable dedupe keys, lease recovery for abandoned tasks, budget-aware deferral that does not burn attempts, and a dedicated AI queue worker. File descriptors carry their ALT map and the blog renders it through the shared resolver, after the article's own override. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR --- .../20261004002941_ai_alt_queue/migration.sql | 45 + .../20261004002941_ai_alt_queue/snapshot.json | 12600 ++++++++++++++++ .../vitnode/src/api/adapters/storage/local.ts | 13 +- .../vitnode/src/api/lib/ai/alt-actions.ts | 122 + .../src/api/lib/ai/alt.integration.test.ts | 377 + packages/vitnode/src/api/lib/ai/alt.ts | 499 + packages/vitnode/src/api/lib/queue.ts | 23 +- .../src/api/middlewares/global.middleware.ts | 1 + packages/vitnode/src/api/models/ai.ts | 2 +- packages/vitnode/src/api/models/events.ts | 6 + packages/vitnode/src/api/models/queue.test.ts | 10 +- packages/vitnode/src/api/models/queue.ts | 14 +- packages/vitnode/src/api/models/storage.ts | 51 + .../api/modules/admin/ai/ai.admin.module.ts | 3 + .../api/modules/admin/ai/routes/alt.route.ts | 100 + .../modules/admin/files/files.admin.module.ts | 16 +- .../modules/admin/files/routes/alt.route.ts | 237 + .../modules/ai/ai-routes.integration.test.ts | 8 +- .../vitnode/src/api/modules/ai/ai.module.ts | 5 +- .../src/api/modules/ai/cron/alt.cron.ts | 34 + .../api/modules/ai/tasks/alt-generate.task.ts | 17 + .../queue/helpers/process-queue-tasks.ts | 183 +- packages/vitnode/src/api/plugin.ts | 6 + packages/vitnode/src/content/files.ts | 6 + packages/vitnode/src/content/server/files.ts | 16 +- packages/vitnode/src/database/files.ts | 116 +- packages/vitnode/src/database/queue.ts | 13 +- .../vitnode/src/lib/files/resolve-alt.test.ts | 48 + packages/vitnode/src/lib/files/resolve-alt.ts | 53 + plugins/blog/src/locales/en.json | 3 +- plugins/blog/src/pages/post-page.tsx | 13 +- .../src/views/admin/article/editor/fields.tsx | 12 +- 32 files changed, 14584 insertions(+), 68 deletions(-) create mode 100644 apps/api/migrations/20261004002941_ai_alt_queue/migration.sql create mode 100644 apps/api/migrations/20261004002941_ai_alt_queue/snapshot.json create mode 100644 packages/vitnode/src/api/lib/ai/alt-actions.ts create mode 100644 packages/vitnode/src/api/lib/ai/alt.integration.test.ts create mode 100644 packages/vitnode/src/api/lib/ai/alt.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/alt.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/files/routes/alt.route.ts create mode 100644 packages/vitnode/src/api/modules/ai/cron/alt.cron.ts create mode 100644 packages/vitnode/src/api/modules/ai/tasks/alt-generate.task.ts create mode 100644 packages/vitnode/src/lib/files/resolve-alt.test.ts create mode 100644 packages/vitnode/src/lib/files/resolve-alt.ts diff --git a/apps/api/migrations/20261004002941_ai_alt_queue/migration.sql b/apps/api/migrations/20261004002941_ai_alt_queue/migration.sql new file mode 100644 index 000000000..536ab2761 --- /dev/null +++ b/apps/api/migrations/20261004002941_ai_alt_queue/migration.sql @@ -0,0 +1,45 @@ +CREATE TABLE "core_files_alt" ( + "id" serial PRIMARY KEY, + "fileId" integer NOT NULL, + "languageCode" varchar(32) NOT NULL, + "text" text NOT NULL, + "origin" varchar(10) NOT NULL, + "fileFingerprint" varchar(64), + "runId" integer, + "updatedById" integer, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_files_alt" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_files_alt_analysis" ( + "id" serial PRIMARY KEY, + "fileId" integer NOT NULL, + "fileFingerprint" varchar(64) NOT NULL, + "description" text NOT NULL, + "runId" integer, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_files_alt_analysis" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_files_alt_state" ( + "fileId" integer PRIMARY KEY, + "status" varchar(20) NOT NULL, + "lastError" varchar(255), + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_files_alt_state" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "core_files" ADD COLUMN "fingerprint" varchar(64);--> statement-breakpoint +ALTER TABLE "core_files" ADD COLUMN "altPolicy" varchar(16) DEFAULT 'automatic' NOT NULL;--> statement-breakpoint +ALTER TABLE "core_queue" ADD COLUMN "dedupeKey" varchar(255);--> statement-breakpoint +CREATE UNIQUE INDEX "core_files_alt_file_language_unique" ON "core_files_alt" ("fileId","languageCode");--> statement-breakpoint +CREATE UNIQUE INDEX "core_files_alt_analysis_file_unique" ON "core_files_alt_analysis" ("fileId","fileFingerprint");--> statement-breakpoint +CREATE INDEX "core_files_alt_state_status_idx" ON "core_files_alt_state" ("status") WHERE status <> 'completed';--> statement-breakpoint +CREATE UNIQUE INDEX "core_queue_dedupe_active_unique" ON "core_queue" ("pluginId","dedupeKey") WHERE "dedupeKey" IS NOT NULL AND status IN ('pending', 'processing');--> statement-breakpoint +ALTER TABLE "core_files_alt" ADD CONSTRAINT "core_files_alt_fileId_core_files_id_fkey" FOREIGN KEY ("fileId") REFERENCES "core_files"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_files_alt" ADD CONSTRAINT "core_files_alt_languageCode_core_languages_code_fkey" FOREIGN KEY ("languageCode") REFERENCES "core_languages"("code") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_files_alt" ADD CONSTRAINT "core_files_alt_runId_core_ai_runs_id_fkey" FOREIGN KEY ("runId") REFERENCES "core_ai_runs"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_files_alt" ADD CONSTRAINT "core_files_alt_updatedById_core_users_id_fkey" FOREIGN KEY ("updatedById") REFERENCES "core_users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_files_alt_analysis" ADD CONSTRAINT "core_files_alt_analysis_fileId_core_files_id_fkey" FOREIGN KEY ("fileId") REFERENCES "core_files"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_files_alt_analysis" ADD CONSTRAINT "core_files_alt_analysis_runId_core_ai_runs_id_fkey" FOREIGN KEY ("runId") REFERENCES "core_ai_runs"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_files_alt_state" ADD CONSTRAINT "core_files_alt_state_fileId_core_files_id_fkey" FOREIGN KEY ("fileId") REFERENCES "core_files"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/apps/api/migrations/20261004002941_ai_alt_queue/snapshot.json b/apps/api/migrations/20261004002941_ai_alt_queue/snapshot.json new file mode 100644 index 000000000..295cdffa8 --- /dev/null +++ b/apps/api/migrations/20261004002941_ai_alt_queue/snapshot.json @@ -0,0 +1,12600 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "9ac46019-5dbb-4e3b-8ce6-33f41b5ddacf", + "prevIds": [ + "4dce261e-67cc-4ef1-9c6f-95994b801466" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_pricing", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_analysis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'manual'", + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricing", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "active", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altLanguages", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'automatic'", + "generated": null, + "identity": null, + "name": "altPolicy", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "periodStart", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_budget_periods_scope_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_run_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "costSource", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reconciledAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_reconcile_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "callId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_cost_adjustments_call_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "modelId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "source", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "active", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_pricing_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "budgetPeriodId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_run_period_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actionKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_action_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "leaseExpiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotencyKey\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_file_language_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileFingerprint", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_analysis_file_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status <> 'completed'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_state_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dedupeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"dedupeKey\" IS NOT NULL AND status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_dedupe_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "createdById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_pricing_createdById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_pricing" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_languageCode_core_languages_code_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_analysis_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_analysis_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_state_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_pricing_pkey", + "schema": "public", + "table": "core_ai_pricing", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_pkey", + "schema": "public", + "table": "core_files_alt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_analysis_pkey", + "schema": "public", + "table": "core_files_alt_analysis", + "entityType": "pks" + }, + { + "columns": [ + "fileId" + ], + "nameExplicit": false, + "name": "core_files_alt_state_pkey", + "schema": "public", + "table": "core_files_alt_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/vitnode/src/api/adapters/storage/local.ts b/packages/vitnode/src/api/adapters/storage/local.ts index 651d0dea1..5c7b0d6ad 100644 --- a/packages/vitnode/src/api/adapters/storage/local.ts +++ b/packages/vitnode/src/api/adapters/storage/local.ts @@ -1,4 +1,4 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { @@ -34,6 +34,17 @@ export const LocalStorageAdapter = ({ await rm(resolvePath(key), { force: true }); }, getUrl, + read: async (key: string, { maxBytes }: { maxBytes: number }) => { + const filePath = resolvePath(key); + try { + const { size } = await stat(filePath); + if (size > maxBytes) return null; + + return await readFile(filePath); + } catch { + return null; + } + }, static: { mountPath, root: "./public/uploads", diff --git a/packages/vitnode/src/api/lib/ai/alt-actions.ts b/packages/vitnode/src/api/lib/ai/alt-actions.ts new file mode 100644 index 000000000..993f5d305 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/alt-actions.ts @@ -0,0 +1,122 @@ +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/config"; + +import { defineAiAction } from "./action"; +import { aiActionRef } from "./registry"; + +/** Image types sent to a vision model. Anything else is never analysed. */ +export const ALT_IMAGE_MEDIA_TYPES = [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +] as const; + +/** The language the image-level base description is written in. */ +export const ALT_BASE_LANGUAGE = "en"; + +const unquote = (text: string) => + text + .trim() + .replace(/^["“„'](.*)["”'"]$/su, "$1") + .trim(); + +const languageName = (code: string) => { + try { + return new Intl.DisplayNames(["en"], { type: "language" }).of(code) ?? code; + } catch { + return code; + } +}; + +/** + * Describes one image once, in English - the base every language is + * translated from. Runs only as the system actor: there is no user whose + * points it could spend. + */ +export const altGenerateAiAction = defineAiAction({ + actors: ["system"], + buildPrompt: (input, { instructions }) => ({ + messages: [ + { + content: [ + { + text: "Write the alternative text for this image.", + type: "text", + }, + { data: input.image, mediaType: input.mediaType, type: "file" }, + ], + role: "user", + }, + ], + system: [ + "You write alternative text (ALT) for images on a website, for people using screen readers.", + "Describe what matters in the image concisely and accurately, usually in one sentence.", + "Do not start with 'Image of' or 'Picture of'. Do not guess names of people. Transcribe short visible text when it matters.", + "Write in English. Answer with the ALT text only, without quotes or notes.", + ...(instructions ? [instructions] : []), + ].join("\n"), + }), + defaults: { + maxImages: 1, + maxInputCharacters: 1, + maxOutputTokens: 200, + maxRetries: 1, + timeoutMs: 60_000, + }, + description: "Describes an image in core_files as its base ALT text.", + id: "media.alt.generate", + inputSchema: z.object({ + image: z.instanceof(Uint8Array), + mediaType: z.enum(ALT_IMAGE_MEDIA_TYPES), + }), + measureInput: () => 0, + output: "text", + outputSchema: z.string().min(1).max(500), + parseText: text => unquote(text), + permission: { defaultGranted: false, key: "media.alt" }, + promptVersion: 1, + requiredCapabilities: ["text", "image-input"], +}); + +/** Translates a base description into one site language. */ +export const altTranslateAiAction = defineAiAction({ + actors: ["system"], + buildPrompt: (input, { instructions }) => ({ + prompt: input.text, + system: [ + `Translate this image description (ALT text) from English into ${languageName(input.to)}.`, + "Keep it concise and natural for a screen reader user.", + "Answer with the translation only, without quotes or notes.", + ...(instructions ? [instructions] : []), + ].join("\n"), + }), + defaults: { + maxInputCharacters: 1_000, + maxOutputTokens: 300, + maxRetries: 1, + timeoutMs: 30_000, + }, + description: "Translates an image's base ALT text into a site language.", + id: "media.alt.translate", + inputSchema: z.object({ + text: z.string().min(1).max(1_000), + to: z.string().min(2).max(32), + }), + output: "text", + outputSchema: z.string().min(1).max(500), + parseText: text => unquote(text), + permission: { defaultGranted: false, key: "media.alt" }, + promptVersion: 1, + requiredCapabilities: ["text"], +}); + +export const ALT_GENERATE_AI_ACTION = aiActionRef( + CONFIG_PLUGIN.pluginId, + altGenerateAiAction, +); +export const ALT_TRANSLATE_AI_ACTION = aiActionRef( + CONFIG_PLUGIN.pluginId, + altTranslateAiAction, +); diff --git a/packages/vitnode/src/api/lib/ai/alt.integration.test.ts b/packages/vitnode/src/api/lib/ai/alt.integration.test.ts new file mode 100644 index 000000000..1db994736 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/alt.integration.test.ts @@ -0,0 +1,377 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { MockLanguageModelV4 } from "ai/test"; +import { and, eq } from "drizzle-orm"; +import { createHash } from "node:crypto"; +import { afterAll, beforeAll, beforeEach, expect, it } from "vitest"; + +import type { EnvVitNode } from "@/api/middlewares/global.middleware"; +import type { AIModelDefinition } from "@/api/models/ai"; +import type { TestDatabaseHandle } from "@/tests/postgres"; + +import { QueueDeferError } from "@/api/lib/queue"; +import { AIModel } from "@/api/models/ai"; +import { QueueModel } from "@/api/models/queue"; +import { processQueueTasks } from "@/api/modules/queue/helpers/process-queue-tasks"; +import * as aiTables from "@/database/ai"; +import { core_ai_runs, core_ai_settings } from "@/database/ai"; +import * as filesTables from "@/database/files"; +import { + core_files, + core_files_alt, + core_files_alt_analysis, + core_files_alt_state, +} from "@/database/files"; +import * as languagesTables from "@/database/languages"; +import { core_languages } from "@/database/languages"; +import * as queueTables from "@/database/queue"; +import { core_queue } from "@/database/queue"; +import * as rolesTables from "@/database/roles"; +import * as usersTables from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createTestDatabase, describePostgres } from "@/tests/postgres"; + +import { + ALT_QUEUE, + ALT_TASK_NAME, + detectMissingAlt, + enqueueAltGeneration, + processAltForFile, +} from "./alt"; +import { altGenerateAiAction, altTranslateAiAction } from "./alt-actions"; +import { collectAiActions } from "./registry"; + +const generatedText = (text: string) => ({ + content: [{ text, type: "text" as const }], + finishReason: { raw: "stop", unified: "stop" as const }, + usage: { + inputTokens: { cacheRead: 0, cacheWrite: 0, noCache: 100, total: 100 }, + outputTokens: { reasoning: 0, text: 10, total: 10 }, + }, + warnings: [], +}); + +/** Answers a vision prompt with a description, anything else with a translation. */ +const createModel = () => { + const calls = { translate: [] as string[], vision: 0 }; + const model = new MockLanguageModelV4({ + doGenerate: async options => { + const prompt = JSON.stringify(options.prompt); + if (prompt.includes('"type":"file"')) { + calls.vision += 1; + + return Promise.resolve(generatedText("A red bicycle.")); + } + const language = /into ([A-Za-z]+)/.exec(prompt)?.[1] ?? "?"; + calls.translate.push(language); + + return Promise.resolve(generatedText(`[${language}] A red bicycle.`)); + }, + }); + + return { calls, model }; +}; + +describePostgres("automatic ALT (real PostgreSQL)", () => { + let database: TestDatabaseHandle; + let calls: ReturnType["calls"]; + let models: AIModelDefinition[]; + let bytes = Buffer.from("first version of the image"); + const cache = createTestCache(); + const aiActions = collectAiActions([ + { + aiActions: [altGenerateAiAction, altTranslateAiAction], + pluginId: "@vitnode/core", + }, + ]); + + const context = (): Context => { + const store = new Map(); + const c = { + get: (key: string) => store.get(key), + set: (key: string, value: unknown) => store.set(key, value), + } as unknown as Context; + store.set("db", database.db); + store.set("cache", cache); + store.set("core", { + ai: { models }, + aiActions, + queue: [ + { + handler: async ( + ctx: Context, + payload: { fileId: number }, + ) => await processAltForFile(ctx, payload.fileId), + leaseSeconds: 900, + maxAttempts: 3, + module: "ai", + name: ALT_TASK_NAME, + pluginId: "@vitnode/core", + }, + ], + }); + store.set("events", { emit: async () => await Promise.resolve() }); + store.set("log", { error: async () => {}, warn: async () => {} }); + store.set("queue", new QueueModel(c)); + store.set("storage", { + readBytes: async () => await Promise.resolve(bytes), + }); + store.set("user", null); + store.set("admin", null); + store.set("ai", new AIModel(c)); + + return c; + }; + + const insertFile = async ( + overrides: Partial = {}, + ) => { + const [file] = await database.db + .insert(core_files) + .values({ + folder: "test", + key: `key-${Math.random()}`, + mimeType: "image/png", + name: "bike.png", + size: 10, + ...overrides, + }) + .returning({ id: core_files.id }); + + return file.id; + }; + + const altsOf = async (fileId: number) => + Object.fromEntries( + ( + await database.db + .select() + .from(core_files_alt) + .where(eq(core_files_alt.fileId, fileId)) + ).map(row => [row.languageCode, { origin: row.origin, text: row.text }]), + ); + + beforeAll(async () => { + database = await createTestDatabase({ + ...aiTables, + ...filesTables, + ...languagesTables, + ...queueTables, + ...rolesTables, + ...usersTables, + }); + await database.db.insert(core_languages).values([ + { code: "en", default: true, name: "English" }, + { code: "pl", name: "Polski" }, + ]); + }, 60_000); + + beforeEach(async () => { + const created = createModel(); + calls = created.calls; + models = [ + { + capabilities: ["text", "image-input"], + id: "vision", + model: created.model, + name: "Vision", + pricing: { rates: { inputPerMillion: "1", outputPerMillion: "2" } }, + }, + ]; + bytes = Buffer.from("first version of the image"); + await database.db + .insert(core_ai_settings) + .values({ altEnabled: true, id: 1, monthlyBudgetUsd: "100" }) + .onConflictDoUpdate({ + set: { + altEnabled: true, + monthlyBudgetUsd: "100", + systemConcurrency: 2, + }, + target: core_ai_settings.id, + }); + }); + + afterAll(async () => { + await database?.drop(); + }); + + it("describes an image once and translates it into every other language", async () => { + const fileId = await insertFile(); + + const result = await processAltForFile(context(), fileId); + + expect(result.written.sort()).toEqual(["en", "pl"]); + expect(calls).toEqual({ translate: ["Polish"], vision: 1 }); + expect(await altsOf(fileId)).toEqual({ + en: { origin: "ai", text: "A red bicycle." }, + pl: { origin: "ai", text: "[Polish] A red bicycle." }, + }); + const runs = await database.db + .select({ + actorType: core_ai_runs.actorType, + userId: core_ai_runs.userId, + }) + .from(core_ai_runs) + .where(eq(core_ai_runs.resourceId, String(fileId))); + expect(runs).toEqual([ + { actorType: "system", userId: null }, + { actorType: "system", userId: null }, + ]); + }); + + it("never overwrites a person's text, even an intentionally empty one", async () => { + const fileId = await insertFile(); + await database.db.insert(core_files_alt).values({ + fileId, + languageCode: "pl", + origin: "human", + text: "", + }); + + await processAltForFile(context(), fileId); + + expect(await altsOf(fileId)).toEqual({ + en: { origin: "ai", text: "A red bicycle." }, + pl: { origin: "human", text: "" }, + }); + expect(calls.translate).toEqual([]); + }); + + it("fills only the new language when one is added, reusing the analysis", async () => { + const fileId = await insertFile(); + await processAltForFile(context(), fileId); + await database.db + .insert(core_languages) + .values({ code: "de", name: "Deutsch" }); + const before = { ...calls, translate: [...calls.translate] }; + + await processAltForFile(context(), fileId); + + expect(calls.vision).toBe(before.vision); + expect(calls.translate).toEqual([...before.translate, "German"]); + expect((await altsOf(fileId)).de).toEqual({ + origin: "ai", + text: "[German] A red bicycle.", + }); + await database.db + .delete(core_languages) + .where(eq(core_languages.code, "de")); + }); + + it("re-describes a changed file, keeping what people wrote", async () => { + const fileId = await insertFile(); + await processAltForFile(context(), fileId); + await database.db + .update(core_files_alt) + .set({ origin: "human", text: "Mój rower" }) + .where( + and( + eq(core_files_alt.fileId, fileId), + eq(core_files_alt.languageCode, "pl"), + ), + ); + + // A replaced file gets a new fingerprint; its AI texts are now stale. + bytes = Buffer.from("second version of the image"); + await database.db + .update(core_files) + .set({ fingerprint: createHash("sha256").update(bytes).digest("hex") }) + .where(eq(core_files.id, fileId)); + await processAltForFile(context(), fileId); + + expect(calls.vision).toBe(2); + const analyses = await database.db + .select() + .from(core_files_alt_analysis) + .where(eq(core_files_alt_analysis.fileId, fileId)); + expect(analyses).toHaveLength(2); + expect(await altsOf(fileId)).toEqual({ + en: { origin: "ai", text: "A red bicycle." }, + pl: { origin: "human", text: "Mój rower" }, + }); + }); + + it("waits for budget without calling the provider or burning a retry", async () => { + const fileId = await insertFile(); + await database.db + .update(core_ai_settings) + .set({ monthlyBudgetUsd: "0.00000001" }) + .where(eq(core_ai_settings.id, 1)); + + await expect(processAltForFile(context(), fileId)).rejects.toBeInstanceOf( + QueueDeferError, + ); + expect(calls.vision).toBe(0); + const [state] = await database.db + .select({ status: core_files_alt_state.status }) + .from(core_files_alt_state) + .where(eq(core_files_alt_state.fileId, fileId)); + expect(state.status).toBe("waiting_budget"); + + // Through the queue: the task returns to pending with its attempt given back. + await database.db.delete(core_queue); + await enqueueAltGeneration(context(), fileId); + await processQueueTasks(context(), { queues: [ALT_QUEUE] }); + const [task] = await database.db.select().from(core_queue); + expect(task).toMatchObject({ attempts: 0, status: "pending" }); + expect(task.availableAt.getTime()).toBeGreaterThan(Date.now()); + }); + + it("queues one task per image however often it is dispatched", async () => { + await database.db.delete(core_queue); + const fileId = await insertFile(); + + const results = await Promise.all([ + enqueueAltGeneration(context(), fileId), + enqueueAltGeneration(context(), fileId), + enqueueAltGeneration(context(), fileId), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + expect(await database.db.select().from(core_queue)).toHaveLength(1); + }); + + it("recovers a task whose worker died, spending the attempt", async () => { + await database.db.delete(core_queue); + const fileId = await insertFile(); + await enqueueAltGeneration(context(), fileId); + await database.db.update(core_queue).set({ + attempts: 1, + reservedAt: new Date(Date.now() - 3_600_000), + status: "processing", + }); + + await processQueueTasks(context(), { queues: [ALT_QUEUE] }); + + const [task] = await database.db.select().from(core_queue); + expect(task).toMatchObject({ attempts: 2, status: "completed" }); + expect(Object.keys(await altsOf(fileId)).sort()).toEqual(["en", "pl"]); + }); + + it("sweeps in bounded batches, skips opted-out files and wraps around", async () => { + await database.db.delete(core_queue); + await database.db.delete(core_files); + const ids = []; + for (let index = 0; index < 3; index++) ids.push(await insertFile()); + await insertFile({ altPolicy: "disabled" }); + await insertFile({ mimeType: "application/pdf" }); + await database.db + .update(core_ai_settings) + .set({ altBatchSize: 2, altScanCursor: 0 }) + .where(eq(core_ai_settings.id, 1)); + + const first = await detectMissingAlt(context()); + const second = await detectMissingAlt(context()); + + expect(first.enqueued).toBe(2); + expect(second.enqueued).toBe(1); + const queued = (await database.db.select().from(core_queue)).map( + row => (row.payload as { fileId: number }).fileId, + ); + expect(queued.sort((a, b) => a - b)).toEqual(ids); + const [settings] = await database.db.select().from(core_ai_settings); + expect(settings.altScanCursor).toBe(0); + }); +}); diff --git a/packages/vitnode/src/api/lib/ai/alt.ts b/packages/vitnode/src/api/lib/ai/alt.ts new file mode 100644 index 000000000..635ed1729 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/alt.ts @@ -0,0 +1,499 @@ +import type { Context } from "hono"; + +import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"; +import { createHash } from "node:crypto"; + +import type { EnvVitNode } from "../../middlewares/global.middleware"; +import type { AiSettingsSnapshot } from "./ledger"; + +import { core_ai_runs, core_ai_settings } from "../../../database/ai"; +import { + core_files, + core_files_alt, + core_files_alt_analysis, + core_files_alt_state, +} from "../../../database/files"; +import { core_languages } from "../../../database/languages"; +import { QueueDeferError } from "../queue"; +import { + ALT_BASE_LANGUAGE, + ALT_GENERATE_AI_ACTION, + ALT_IMAGE_MEDIA_TYPES, + ALT_TRANSLATE_AI_ACTION, +} from "./alt-actions"; +import { isAiError } from "./errors"; +import { periodContaining } from "./periods"; + +export const ALT_QUEUE = "ai"; +export const ALT_TASK_NAME = "alt-generate"; +/** The largest original the job will read; bigger files are skipped. */ +export const ALT_MAX_SOURCE_BYTES = 20 * 1024 * 1024; +/** Longest edge of the copy sent to the provider. */ +const ALT_MAX_EDGE_PX = 1024; +/** Files looked at per detection run, beyond the ones enqueued. */ +const ALT_SCAN_WINDOW = 200; + +type AltStatus = (typeof core_files_alt_state.$inferInsert)["status"]; + +type Db = Context["var"]["db"]; + +const isAltImage = (mimeType: null | string): boolean => + (ALT_IMAGE_MEDIA_TYPES as readonly string[]).includes(mimeType ?? ""); + +export const altDedupeKey = (fileId: number) => `alt:${fileId}`; + +const setAltState = async ( + db: Db, + fileId: number, + status: AltStatus, + lastError: null | string = null, +) => { + await db + .insert(core_files_alt_state) + .values({ fileId, lastError, status }) + .onConflictDoUpdate({ + set: { lastError, status, updatedAt: new Date() }, + target: core_files_alt_state.fileId, + }); +}; + +/** + * The languages ALT is written in: every registered site language, or the + * subset an admin chose. `core_languages` has no "active" flag - registered + * is available. + */ +export const altLanguages = async ( + db: Db, + settings: Pick, +): Promise => { + const rows = await db + .select({ code: core_languages.code }) + .from(core_languages) + .orderBy(asc(core_languages.code)); + const codes = rows.map(row => row.code); + + return settings.altLanguages + ? codes.filter(code => settings.altLanguages?.includes(code)) + : codes; +}; + +/** + * Languages a file still needs an AI description in. A human row - even an + * intentionally empty one - is never "missing". An AI row written for an + * older version of the file is. + */ +export const missingAltLanguages = ( + rows: { + fileFingerprint: null | string; + languageCode: string; + origin: string; + }[], + languages: string[], + fingerprint: null | string, +): string[] => + languages.filter(code => { + const row = rows.find(entry => entry.languageCode === code); + if (!row) return true; + if (row.origin === "human") return false; + + return fingerprint !== null && row.fileFingerprint !== fingerprint; + }); + +/** Queues one image. Durable dedupe: a file already queued is queued once. */ +export const enqueueAltGeneration = async ( + c: Context, + fileId: number, +): Promise => { + const { deduplicated } = await c.get("queue").dispatch({ + dedupeKey: altDedupeKey(fileId), + maxAttempts: 3, + name: ALT_TASK_NAME, + payload: { fileId }, + pluginId: "@vitnode/core", + queue: ALT_QUEUE, + }); + if (!deduplicated) await setAltState(c.get("db"), fileId, "pending"); + + return !deduplicated; +}; + +/** + * Called after an upload: an image gets its ALT job right away, when the + * feature is on. A failure here never fails the upload - the hourly sweep is + * the repair path. + */ +export const enqueueAltAfterUpload = async ( + c: Context, + file: { id: number; mimeType: null | string }, +): Promise => { + if (!isAltImage(file.mimeType)) return; + try { + const settings = await c.get("ai").ledger().loadSettings(); + if (!settings.enabled || !settings.altEnabled) return; + await enqueueAltGeneration(c, file.id); + } catch { + /* the sweep catches up */ + } +}; + +/** + * The repair sweep: bounded batches in id order from a stored cursor, so it + * resumes where it stopped. When it reaches the end it starts over, so new + * languages and missed uploads are always found again - nothing starves. + */ +export const detectMissingAlt = async ( + c: Context, +): Promise<{ enqueued: number; scanned: number }> => { + const db = c.get("db"); + const ledger = c.get("ai").ledger(); + const settings = await ledger.loadSettings(); + if (!settings.enabled || !settings.altEnabled) { + return { enqueued: 0, scanned: 0 }; + } + + const [row] = await db + .select({ cursor: core_ai_settings.altScanCursor }) + .from(core_ai_settings) + .where(eq(core_ai_settings.id, 1)); + const cursor = row?.cursor ?? 0; + const languages = await altLanguages(db, settings); + if (languages.length === 0) return { enqueued: 0, scanned: 0 }; + + const files = await db + .select({ + fingerprint: core_files.fingerprint, + id: core_files.id, + }) + .from(core_files) + .where( + and( + gt(core_files.id, cursor), + eq(core_files.altPolicy, "automatic"), + inArray(core_files.mimeType, [...ALT_IMAGE_MEDIA_TYPES]), + ), + ) + .orderBy(asc(core_files.id)) + .limit(ALT_SCAN_WINDOW); + + const rows = + files.length === 0 + ? [] + : await db + .select({ + fileFingerprint: core_files_alt.fileFingerprint, + fileId: core_files_alt.fileId, + languageCode: core_files_alt.languageCode, + origin: core_files_alt.origin, + }) + .from(core_files_alt) + .where( + inArray( + core_files_alt.fileId, + files.map(file => file.id), + ), + ); + + let enqueued = 0; + let lastId = cursor; + for (const file of files) { + if (enqueued >= settings.altBatchSize) break; + lastId = file.id; + const missing = missingAltLanguages( + rows.filter(entry => entry.fileId === file.id), + languages, + file.fingerprint, + ); + if (missing.length === 0) continue; + if (await enqueueAltGeneration(c, file.id)) enqueued += 1; + } + + // Wrap to the start once the end was reached, so the next sweep revisits + // every image - a language added since is noticed there. + const nextCursor = + files.length < ALT_SCAN_WINDOW && enqueued < settings.altBatchSize + ? 0 + : lastId; + await db + .update(core_ai_settings) + .set({ + altScanCursor: nextCursor, + altScanStartedAt: cursor === 0 ? new Date() : undefined, + }) + .where(eq(core_ai_settings.id, 1)); + + return { enqueued, scanned: files.length }; +}; + +/** A bounded copy of the image for the provider, and the original's fingerprint. */ +const prepareImage = async ( + c: Context, + file: { key: string; mimeType: null | string }, +): Promise => { + const original = await c + .get("storage") + .readBytes(file.key, ALT_MAX_SOURCE_BYTES); + if (!original) return null; + const fingerprint = createHash("sha256").update(original).digest("hex"); + const mediaType = file.mimeType as (typeof ALT_IMAGE_MEDIA_TYPES)[number]; + + try { + const { default: sharp } = await import("sharp"); + const resized = await sharp(original, { animated: false }) + .resize(ALT_MAX_EDGE_PX, ALT_MAX_EDGE_PX, { + fit: "inside", + withoutEnlargement: true, + }) + .webp({ quality: 80 }) + .toBuffer(); + + return { bytes: resized, fingerprint, mediaType: "image/webp" }; + } catch { + // Without sharp the original goes as it is - still bounded by its size. + return { bytes: original, fingerprint, mediaType }; + } +}; + +/** + * Writes one AI description, only if a person has not written that language + * and the file is still the version it was written for. Never overwrites a + * human row; never writes for a file whose policy changed meanwhile. + */ +export const writeAiAlt = async ( + db: Db, + { + fileId, + fingerprint, + languageCode, + runId, + text, + }: { + fileId: number; + fingerprint: string; + languageCode: string; + runId: null | number; + text: string; + }, +): Promise => { + const result = await db.execute<{ id: number }>(sql` + INSERT INTO ${core_files_alt} + ("fileId", "languageCode", "text", "origin", "fileFingerprint", "runId", "updatedAt") + SELECT ${fileId}, ${languageCode}, ${text}, 'ai', ${fingerprint}, ${runId}, now() + WHERE EXISTS ( + SELECT 1 FROM ${core_files} + WHERE ${core_files.id} = ${fileId} + AND ${core_files.fingerprint} = ${fingerprint} + AND ${core_files.altPolicy} = 'automatic' + ) + ON CONFLICT ("fileId", "languageCode") DO UPDATE SET + "text" = excluded."text", + "fileFingerprint" = excluded."fileFingerprint", + "runId" = excluded."runId", + "updatedAt" = now() + WHERE ${core_files_alt}."origin" = 'ai' + AND ${core_files_alt}."fileFingerprint" IS DISTINCT FROM excluded."fileFingerprint" + RETURNING "id" + `); + + return result.length > 0; +}; + +const deferForBudget = async ( + db: Db, + fileId: number, + settings: AiSettingsSnapshot, +): Promise => { + await setAltState(db, fileId, "waiting_budget"); + // The budget frees up at the start of the next period - no point asking + // the provider, or burning a retry, before then. Checked hourly meanwhile in + // case an admin raises the budget. + const nextPeriod = periodContaining( + new Date(), + "month", + settings.timeZone, + ).end; + const inAnHour = new Date(Date.now() + 3_600_000); + throw new QueueDeferError( + nextPeriod < inAnHour ? nextPeriod : inAnHour, + "Waiting for the AI budget", + ); +}; + +const BUDGET_CODES = new Set(["AI_BUDGET_EXHAUSTED", "AI_DISABLED"]); + +/** + * One image, end to end: check, read, describe once, translate into the + * missing languages, write conditionally. Every write rechecks the file's + * version; persisted results are reused on retry, so a crash costs at most + * the call that was in flight. + */ +export const processAltForFile = async ( + c: Context, + fileId: number, +): Promise<{ written: string[] }> => { + const db = c.get("db"); + const settings = await c.get("ai").ledger().loadSettings(); + if (!settings.enabled || !settings.altEnabled) { + await deferForBudget(db, fileId, settings); + } + + const [file] = await db + .select({ + altPolicy: core_files.altPolicy, + fingerprint: core_files.fingerprint, + id: core_files.id, + key: core_files.key, + mimeType: core_files.mimeType, + }) + .from(core_files) + .where(eq(core_files.id, fileId)); + if (!file) return { written: [] }; + if (file.altPolicy !== "automatic" || !isAltImage(file.mimeType)) { + await setAltState(db, fileId, "skipped"); + + return { written: [] }; + } + + const languages = await altLanguages(db, settings); + const existing = await db + .select({ + fileFingerprint: core_files_alt.fileFingerprint, + languageCode: core_files_alt.languageCode, + origin: core_files_alt.origin, + }) + .from(core_files_alt) + .where(eq(core_files_alt.fileId, fileId)); + if (missingAltLanguages(existing, languages, file.fingerprint).length === 0) { + await setAltState(db, fileId, "completed"); + + return { written: [] }; + } + + const image = await prepareImage(c, file); + if (!image) { + await setAltState( + db, + fileId, + "skipped", + "The file could not be read or is too large", + ); + + return { written: [] }; + } + if (file.fingerprint !== image.fingerprint) { + // An older file, or one whose bytes changed: its AI texts no longer match. + await db + .update(core_files) + .set({ fingerprint: image.fingerprint }) + .where(eq(core_files.id, fileId)); + } + const fingerprint = image.fingerprint; + const missing = missingAltLanguages(existing, languages, fingerprint); + + let [analysis] = await db + .select() + .from(core_files_alt_analysis) + .where( + and( + eq(core_files_alt_analysis.fileId, fileId), + eq(core_files_alt_analysis.fileFingerprint, fingerprint), + ), + ); + + try { + if (!analysis) { + // Another worker may still be describing this exact version (a lease + // that has not expired). Wait for it instead of paying twice. A run + // that died is settled by maintenance; after that, one new call is the + // price of a crash - exactly-once is not promised. + const [inFlight] = await db + .select({ id: core_ai_runs.id }) + .from(core_ai_runs) + .where( + and( + eq(core_ai_runs.actionKey, ALT_GENERATE_AI_ACTION), + eq(core_ai_runs.resourceId, String(fileId)), + eq(core_ai_runs.sourceFingerprint, fingerprint), + eq(core_ai_runs.settlement, "pending"), + gt(core_ai_runs.leaseExpiresAt, new Date()), + ), + ) + .limit(1); + if (inFlight) { + throw new QueueDeferError( + new Date(Date.now() + 5 * 60_000), + "The image is already being described", + ); + } + + const result = await c.get("ai").runAsSystem({ + action: ALT_GENERATE_AI_ACTION, + input: { image: image.bytes, mediaType: image.mediaType }, + resource: { id: fileId, type: "core.file" }, + sourceFingerprint: fingerprint, + }); + [analysis] = await db + .insert(core_files_alt_analysis) + .values({ + description: result.output, + fileFingerprint: fingerprint, + fileId, + runId: result.runId, + }) + .onConflictDoUpdate({ + set: { description: result.output, runId: result.runId }, + target: [ + core_files_alt_analysis.fileId, + core_files_alt_analysis.fileFingerprint, + ], + }) + .returning(); + } + + const written: string[] = []; + for (const languageCode of missing) { + let text = analysis.description; + let runId = analysis.runId; + if (languageCode !== ALT_BASE_LANGUAGE) { + const translated = await c.get("ai").runAsSystem({ + action: ALT_TRANSLATE_AI_ACTION, + input: { text: analysis.description, to: languageCode }, + resource: { id: fileId, type: "core.file" }, + sourceFingerprint: fingerprint, + }); + text = translated.output; + runId = translated.runId; + } + if ( + await writeAiAlt(db, { fileId, fingerprint, languageCode, runId, text }) + ) { + written.push(languageCode); + } + } + + await setAltState(db, fileId, "completed"); + if (written.length > 0) { + await c + .get("events") + ?.emit("files.alt.updated", { + fileId, + languageCodes: written, + origin: "ai", + }) + .catch(() => undefined); + } + + return { written }; + } catch (error) { + if (isAiError(error) && BUDGET_CODES.has(error.code)) { + return await deferForBudget(db, fileId, settings); + } + if (error instanceof QueueDeferError) throw error; + const message = isAiError(error) ? error.code : "ALT generation failed"; + await setAltState(db, fileId, "failed", message); + throw error; + } +}; diff --git a/packages/vitnode/src/api/lib/queue.ts b/packages/vitnode/src/api/lib/queue.ts index 8ef871bbb..6090746d8 100644 --- a/packages/vitnode/src/api/lib/queue.ts +++ b/packages/vitnode/src/api/lib/queue.ts @@ -8,6 +8,11 @@ export interface BuildQueueTaskReturn { c: Context, payload: Record, ) => Promise | void; + /** + * How long one run may take before the task counts as abandoned and is + * picked up again (the attempt is spent). Default 10 minutes. + */ + leaseSeconds?: number; maxAttempts?: number; name: string; } @@ -21,7 +26,23 @@ export function buildQueueTask({ name, handler, description, + leaseSeconds, maxAttempts, }: BuildQueueTaskReturn): BuildQueueTaskReturn { - return { name, handler, description, maxAttempts }; + return { name, handler, description, leaseSeconds, maxAttempts }; +} + +/** + * Thrown by a task that cannot run *yet* - a budget is used up, a resource + * is busy. The task goes back to pending until `until`, and the attempt is + * not counted: waiting is not failing. + */ +export class QueueDeferError extends Error { + constructor(until: Date, reason: string) { + super(reason); + this.name = "QueueDeferError"; + this.until = until; + } + + readonly until: Date; } diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index cee2a2645..06e8e4e3c 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -295,6 +295,7 @@ export const globalMiddleware = ({ handler: task.handler, description: task.description, maxAttempts: task.maxAttempts, + leaseSeconds: task.leaseSeconds, })), ); diff --git a/packages/vitnode/src/api/models/ai.ts b/packages/vitnode/src/api/models/ai.ts index 54caf731b..07a777c62 100644 --- a/packages/vitnode/src/api/models/ai.ts +++ b/packages/vitnode/src/api/models/ai.ts @@ -6,12 +6,12 @@ import { HTTPException } from "hono/http-exception"; import type { AiModelCapability } from "../lib/ai/capabilities"; import type { AiLedger } from "../lib/ai/ledger"; import type { AiPricing } from "../lib/ai/pricing"; +import type { TypedAiActionKey } from "../lib/ai/registry"; import type { AiRunRequest, AiRunResult, AiStreamResult, } from "../lib/ai/runner"; -import type { TypedAiActionKey } from "../lib/ai/registry"; import type { AiProviderAdapter } from "../lib/ai/usage-cost"; import { DEFAULT_AI_MODEL_CAPABILITIES } from "../lib/ai/capabilities"; diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 2afa3e254..26e848c35 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -27,6 +27,12 @@ export interface VitNodeEvents { changedZones: string[]; pageId: string; }; + /** Default ALT text of an image in core_files was written. */ + "files.alt.updated": { + fileId: number; + languageCodes: string[]; + origin: "ai" | "human"; + }; "navigation.created": { navigationId: number; }; diff --git a/packages/vitnode/src/api/models/queue.test.ts b/packages/vitnode/src/api/models/queue.test.ts index 382f9eb89..dcc2fb1fa 100644 --- a/packages/vitnode/src/api/models/queue.test.ts +++ b/packages/vitnode/src/api/models/queue.test.ts @@ -12,11 +12,13 @@ const harness = ({ plugin }: { plugin?: string } = {}) => { const handle = (name: string) => ({ insert: () => ({ values: (values: Record) => ({ - returning: async () => { - inserts.push({ handle: name, values }); + onConflictDoNothing: () => ({ + returning: async () => { + inserts.push({ handle: name, values }); - return await Promise.resolve([{ id: 1 }]); - }, + return await Promise.resolve([{ id: 1 }]); + }, + }), }), }), }); diff --git a/packages/vitnode/src/api/models/queue.ts b/packages/vitnode/src/api/models/queue.ts index 6490f21d8..c7500a3a5 100644 --- a/packages/vitnode/src/api/models/queue.ts +++ b/packages/vitnode/src/api/models/queue.ts @@ -4,6 +4,11 @@ import { core_queue } from "@/database/queue"; export interface QueueDispatchArgs { availableAt?: Date; + /** + * Durable deduplication: while a task with this key is pending or running, + * dispatching it again adds nothing. Enforced by a unique index. + */ + dedupeKey?: string; maxAttempts?: number; name: string; payload?: Record; @@ -23,6 +28,7 @@ export class QueueModel { protected readonly c: Context; async dispatch({ + dedupeKey, name, payload = {}, pluginId: explicitPluginId, @@ -31,7 +37,7 @@ export class QueueModel { maxAttempts, availableAt, tx, - }: QueueDispatchArgs): Promise<{ id: number }> { + }: QueueDispatchArgs): Promise<{ deduplicated: boolean; id: null | number }> { const pluginId = explicitPluginId ?? this.c.get("plugin")?.id ?? "@vitnode/core"; @@ -47,11 +53,15 @@ export class QueueModel { queue, payload, priority, + dedupeKey: dedupeKey ?? null, maxAttempts: maxAttempts ?? registeredTask?.maxAttempts ?? 3, availableAt: availableAt ?? new Date(), }) + .onConflictDoNothing() .returning({ id: core_queue.id }); - return row; + return row + ? { deduplicated: false, id: row.id } + : { deduplicated: true, id: null }; } } diff --git a/packages/vitnode/src/api/models/storage.ts b/packages/vitnode/src/api/models/storage.ts index 8dc3e088f..3b8ccec31 100644 --- a/packages/vitnode/src/api/models/storage.ts +++ b/packages/vitnode/src/api/models/storage.ts @@ -2,6 +2,7 @@ import type { Context } from "hono"; import { and, eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; +import { createHash } from "node:crypto"; import type { StorageFileInUseBody } from "@/lib/files/in-use"; @@ -20,6 +21,8 @@ import { import { STORAGE_FILE_IN_USE } from "@/lib/files/in-use"; import { formatBytes } from "@/lib/format-bytes"; +import { enqueueAltAfterUpload } from "../lib/ai/alt"; + const DEFAULT_IMAGE_QUALITY = 85; // Formats sharp can lossily re-encode. SVG/GIF are intentionally excluded to @@ -82,6 +85,12 @@ export interface StorageStaticConfig { export interface StorageApiPlugin { delete: (key: string) => Promise; getUrl: (key: string) => string; + /** + * Reads a stored object's bytes server-side, refusing anything over + * `maxBytes` (`null`). Optional: without it the object is fetched from its + * server-reachable URL - the URL itself is never handed to anyone else. + */ + read?: (key: string, options: { maxBytes: number }) => Promise; static?: StorageStaticConfig; upload: (args: StorageUploadArgs) => Promise; } @@ -418,6 +427,42 @@ export class StorageModel { return this.requireProvider().getUrl(key); } + /** + * A stored object's bytes, bounded: `null` when it is missing or larger than + * `maxBytes`. Used to hand an image to an AI provider as bytes, so a + * private file never has to be made public for it. + */ + async readBytes(key: string, maxBytes: number): Promise { + const provider = this.requireProvider(); + if (provider.read) return await provider.read(key, { maxBytes }); + + const response = await fetch(provider.getUrl(key)); + if (!response.ok || !response.body) return null; + const declared = Number(response.headers.get("content-length") ?? "0"); + if (declared > maxBytes) { + await response.body.cancel(); + + return null; + } + + const chunks: Uint8Array[] = []; + let total = 0; + const reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + + return null; + } + chunks.push(value); + } + + return Buffer.concat(chunks); + } + async upload({ allowedMimeTypes, file, @@ -488,6 +533,9 @@ export class StorageModel { size, userId: ownerId, pluginId: this.c.get("plugin")?.id ?? null, + fingerprint: createHash("sha256") + .update(processed.body) + .digest("hex"), metadata: { ...(metadata ?? {}), ...(processed.dimensions @@ -512,6 +560,9 @@ export class StorageModel { }); } + // Images get their ALT job straight away when automatic ALT is on. + await enqueueAltAfterUpload(this.c, { id: created.id, mimeType }); + return { ...result, dimensions: processed.dimensions, diff --git a/packages/vitnode/src/api/modules/admin/ai/ai.admin.module.ts b/packages/vitnode/src/api/modules/admin/ai/ai.admin.module.ts index 00daa8a4a..10f5d4c23 100644 --- a/packages/vitnode/src/api/modules/admin/ai/ai.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/ai/ai.admin.module.ts @@ -8,6 +8,7 @@ import { updateAiUserOverrideAdminRoute, } from "./routes/access.route"; import { listAiActionsAdminRoute } from "./routes/actions.route"; +import { getAltStatusAdminRoute, sweepAltAdminRoute } from "./routes/alt.route"; import { assistAiAdminRoute } from "./routes/assist.route"; import { listAiHistoryAdminRoute, @@ -46,5 +47,7 @@ export const aiAdminModule = buildModule({ listAiHistoryAdminRoute, showAiRunAdminRoute, assistAiAdminRoute, + getAltStatusAdminRoute, + sweepAltAdminRoute, ], }); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/alt.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/alt.route.ts new file mode 100644 index 000000000..a51e13bd0 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/alt.route.ts @@ -0,0 +1,100 @@ +import { and, count, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; + +import { altLanguages, detectMissingAlt } from "@/api/lib/ai/alt"; +import { ALT_IMAGE_MEDIA_TYPES } from "@/api/lib/ai/alt-actions"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_files, core_files_alt_state } from "@/database/files"; + +export const getAltStatusAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_view" }, + route: { + method: "get", + description: + "Automatic ALT progress: pending, completed, failed and waiting for budget.", + path: "/alt", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + counts: z.object({ + completed: z.number(), + failed: z.number(), + pending: z.number(), + skipped: z.number(), + waitingBudget: z.number(), + }), + eligibleImages: z.number(), + enabled: z.boolean(), + languages: z.array(z.string()), + }), + }, + }, + description: "ALT progress", + }, + }, + }, + handler: async c => { + const db = c.get("db"); + const settings = await c.get("ai").ledger().loadSettings(); + const [states, [eligible], languages] = await Promise.all([ + db + .select({ status: core_files_alt_state.status, total: count() }) + .from(core_files_alt_state) + .groupBy(core_files_alt_state.status), + db + .select({ total: count() }) + .from(core_files) + .where( + and( + inArray(core_files.mimeType, [...ALT_IMAGE_MEDIA_TYPES]), + eq(core_files.altPolicy, "automatic"), + ), + ), + altLanguages(db, settings), + ]); + const of = (status: string) => + states.find(row => row.status === status)?.total ?? 0; + + return c.json( + { + counts: { + completed: of("completed"), + failed: of("failed"), + pending: of("pending"), + skipped: of("skipped"), + waitingBudget: of("waiting_budget"), + }, + eligibleImages: eligible.total, + enabled: settings.enabled && settings.altEnabled, + languages, + }, + 200, + ); + }, +}); + +export const sweepAltAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "ai", permission: "can_manage" }, + route: { + method: "post", + description: + "Look for images missing ALT text now, instead of waiting for the hourly sweep.", + path: "/alt/sweep", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ enqueued: z.number(), scanned: z.number() }), + }, + }, + description: "How many images were queued", + }, + }, + }, + handler: async c => c.json(await detectMissingAlt(c), 200), +}); diff --git a/packages/vitnode/src/api/modules/admin/files/files.admin.module.ts b/packages/vitnode/src/api/modules/admin/files/files.admin.module.ts index cc36b1778..829081edb 100644 --- a/packages/vitnode/src/api/modules/admin/files/files.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/files/files.admin.module.ts @@ -1,6 +1,12 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; +import { + deleteFileAltAdminRoute, + getFileAltAdminRoute, + updateFileAltAdminRoute, + updateFileAltPolicyAdminRoute, +} from "./routes/alt.route"; import { deleteFileAdminRoute } from "./routes/delete.route"; import { downloadFileAdminRoute } from "./routes/download.route"; import { listFilesAdminRoute } from "./routes/list.route"; @@ -8,5 +14,13 @@ import { listFilesAdminRoute } from "./routes/list.route"; export const filesAdminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "files", - routes: [listFilesAdminRoute, downloadFileAdminRoute, deleteFileAdminRoute], + routes: [ + listFilesAdminRoute, + downloadFileAdminRoute, + deleteFileAdminRoute, + getFileAltAdminRoute, + updateFileAltAdminRoute, + deleteFileAltAdminRoute, + updateFileAltPolicyAdminRoute, + ], }); diff --git a/packages/vitnode/src/api/modules/admin/files/routes/alt.route.ts b/packages/vitnode/src/api/modules/admin/files/routes/alt.route.ts new file mode 100644 index 000000000..bbbe66c24 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/files/routes/alt.route.ts @@ -0,0 +1,237 @@ +import { and, asc, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_files, core_files_alt } from "@/database/files"; +import { core_languages } from "@/database/languages"; + +const zodAltPolicy = z.enum(["automatic", "manual", "disabled"]); + +export const getFileAltAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "files", permission: "can_view" }, + route: { + method: "get", + description: "An image's default ALT text in every site language.", + path: "/{id}/alt", + request: { params: z.object({ id: z.coerce.number().int() }) }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + altPolicy: zodAltPolicy, + languages: z.array( + z.object({ + code: z.string(), + name: z.string(), + origin: z.enum(["ai", "human"]).nullable(), + /** An AI text written for an older version of the file. */ + stale: z.boolean(), + text: z.string().nullable(), + updatedAt: z.date().nullable(), + }), + ), + }), + }, + }, + description: "ALT texts", + }, + 404: { description: "No such file" }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const db = c.get("db"); + const [file] = await db + .select({ + altPolicy: core_files.altPolicy, + fingerprint: core_files.fingerprint, + }) + .from(core_files) + .where(eq(core_files.id, id)); + if (!file) throw new HTTPException(404, { message: "File not found" }); + + const [languages, rows] = await Promise.all([ + db + .select({ code: core_languages.code, name: core_languages.name }) + .from(core_languages) + .orderBy(asc(core_languages.code)), + db.select().from(core_files_alt).where(eq(core_files_alt.fileId, id)), + ]); + + return c.json( + { + altPolicy: file.altPolicy, + languages: languages.map(language => { + const row = rows.find(entry => entry.languageCode === language.code); + + return { + code: language.code, + name: language.name, + origin: row?.origin ?? null, + stale: + row?.origin === "ai" && + file.fingerprint !== null && + row.fileFingerprint !== file.fingerprint, + text: row?.text ?? null, + updatedAt: row?.updatedAt ?? null, + }; + }), + }, + 200, + ); + }, +}); + +/** + * A person's ALT text. It always wins: the automatic job never overwrites a + * human row. An empty text is allowed and means "intentionally no + * description" - different from no row at all. + */ +export const updateFileAltAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "files", permission: "can_edit_alt" }, + route: { + method: "put", + description: "Set an image's ALT text in one language.", + path: "/{id}/alt", + request: { + params: z.object({ id: z.coerce.number().int() }), + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + languageCode: z.string().min(2).max(32), + text: z.string().max(1_000), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Saved", + }, + 404: { description: "No such file or language" }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const { languageCode, text } = c.req.valid("json"); + const db = c.get("db"); + const [file] = await db + .select({ fingerprint: core_files.fingerprint }) + .from(core_files) + .where(eq(core_files.id, id)); + if (!file) throw new HTTPException(404, { message: "File not found" }); + + const values = { + fileFingerprint: file.fingerprint, + origin: "human" as const, + runId: null, + text: text.trim(), + updatedById: c.get("admin")?.user.id ?? null, + }; + await db + .insert(core_files_alt) + .values({ ...values, fileId: id, languageCode }) + .onConflictDoUpdate({ + set: { ...values, updatedAt: new Date() }, + target: [core_files_alt.fileId, core_files_alt.languageCode], + }); + await c + .get("events") + .emit("files.alt.updated", { + fileId: id, + languageCodes: [languageCode], + origin: "human", + }) + .catch(() => undefined); + + return c.json({ ok: true as const }, 200); + }, +}); + +/** Removes one language's ALT text, so the automatic job may write it again. */ +export const deleteFileAltAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "files", permission: "can_edit_alt" }, + route: { + method: "delete", + description: "Remove an image's ALT text in one language.", + path: "/{id}/alt", + request: { + params: z.object({ id: z.coerce.number().int() }), + query: z.object({ languageCode: z.string().min(2).max(32) }), + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Removed", + }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const { languageCode } = c.req.valid("query"); + await c + .get("db") + .delete(core_files_alt) + .where( + and( + eq(core_files_alt.fileId, id), + eq(core_files_alt.languageCode, languageCode), + ), + ); + + return c.json({ ok: true as const }, 200); + }, +}); + +export const updateFileAltPolicyAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "files", permission: "can_edit_alt" }, + route: { + method: "put", + description: + "Choose whether automatic ALT may describe this file: automatic, manual only, or disabled (never sent to an external AI).", + path: "/{id}/alt-policy", + request: { + params: z.object({ id: z.coerce.number().int() }), + body: { + required: true, + content: { + "application/json": { schema: z.object({ altPolicy: zodAltPolicy }) }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Saved", + }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const { altPolicy } = c.req.valid("json"); + await c + .get("db") + .update(core_files) + .set({ altPolicy }) + .where(eq(core_files.id, id)); + + return c.json({ ok: true as const }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/ai/ai-routes.integration.test.ts b/packages/vitnode/src/api/modules/ai/ai-routes.integration.test.ts index a5100fe9c..8fe5e0b53 100644 --- a/packages/vitnode/src/api/modules/ai/ai-routes.integration.test.ts +++ b/packages/vitnode/src/api/modules/ai/ai-routes.integration.test.ts @@ -236,7 +236,9 @@ describePostgres("AI routes (real PostgreSQL)", () => { .where(eq(core_ai_runs.userId, ALICE)) ).map(row => row.id); - expect(alice.items.map(item => item.id).sort()).toEqual(aliceRunIds.sort()); + expect(alice.items.map(item => item.id).sort((a, b) => a - b)).toEqual( + aliceRunIds.sort((a, b) => a - b), + ); expect(bob.items).toHaveLength(2); // A cursor from someone else's history widens nothing. const sneaky = (await ( @@ -244,8 +246,8 @@ describePostgres("AI routes (real PostgreSQL)", () => { user: ALICE, }) ).json()) as { items: { id: number }[] }; - expect(sneaky.items.map(item => item.id).sort()).toEqual( - aliceRunIds.sort(), + expect(sneaky.items.map(item => item.id).sort((a, b) => a - b)).toEqual( + aliceRunIds.sort((a, b) => a - b), ); const usage = (await ( diff --git a/packages/vitnode/src/api/modules/ai/ai.module.ts b/packages/vitnode/src/api/modules/ai/ai.module.ts index 5f71647d8..68549ddaf 100644 --- a/packages/vitnode/src/api/modules/ai/ai.module.ts +++ b/packages/vitnode/src/api/modules/ai/ai.module.ts @@ -1,15 +1,18 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; +import { aiQueueCron, altDetectCron } from "./cron/alt.cron"; import { aiMaintenanceCron } from "./cron/maintenance.cron"; import { aiFeedbackRoute } from "./routes/feedback.route"; import { aiHistoryRoute } from "./routes/history.route"; import { runAiRoute } from "./routes/run.route"; import { aiUsageRoute } from "./routes/usage.route"; +import { altGenerateQueueTask } from "./tasks/alt-generate.task"; export const aiModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "ai", routes: [aiUsageRoute, aiHistoryRoute, aiFeedbackRoute, runAiRoute], - cronJobs: [aiMaintenanceCron], + cronJobs: [aiMaintenanceCron, altDetectCron, aiQueueCron], + queueTasks: [altGenerateQueueTask], }); diff --git a/packages/vitnode/src/api/modules/ai/cron/alt.cron.ts b/packages/vitnode/src/api/modules/ai/cron/alt.cron.ts new file mode 100644 index 000000000..64c234767 --- /dev/null +++ b/packages/vitnode/src/api/modules/ai/cron/alt.cron.ts @@ -0,0 +1,34 @@ +import { ALT_QUEUE, detectMissingAlt } from "@/api/lib/ai/alt"; +import { buildCron } from "@/api/lib/cron"; + +import { processQueueTasks } from "../../queue/helpers/process-queue-tasks"; + +/** + * Finds images still missing ALT text in a site language and queues them, in + * bounded batches. The repair path for missed uploads and new languages. + */ +export const altDetectCron = buildCron({ + name: "ai-alt-detect", + description: "Queue images that are missing ALT text in a site language", + schedule: "0 * * * *", + handler: async c => { + await detectMissingAlt(c); + }, +}); + +/** + * Works the AI queue on its own, two tasks at a time, under its own lock - so + * slow model calls never hold up e-mails and other queue work. + */ +export const aiQueueCron = buildCron({ + name: "ai-queue", + description: "Process queued AI tasks (automatic ALT text)", + schedule: "* * * * *", + handler: async c => { + await processQueueTasks(c, { + batchSize: 2, + lockKey: "queue:process:ai", + queues: [ALT_QUEUE], + }); + }, +}); diff --git a/packages/vitnode/src/api/modules/ai/tasks/alt-generate.task.ts b/packages/vitnode/src/api/modules/ai/tasks/alt-generate.task.ts new file mode 100644 index 000000000..d4470315f --- /dev/null +++ b/packages/vitnode/src/api/modules/ai/tasks/alt-generate.task.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +import { ALT_TASK_NAME, processAltForFile } from "@/api/lib/ai/alt"; +import { buildQueueTask } from "@/api/lib/queue"; + +export const altGenerateQueueTask = buildQueueTask({ + name: ALT_TASK_NAME, + description: + "Describe one image and translate its ALT text into missing languages", + maxAttempts: 3, + // One image: a vision call and a few short translations. + leaseSeconds: 15 * 60, + handler: async (c, payload) => { + const { fileId } = z.object({ fileId: z.number().int() }).parse(payload); + await processAltForFile(c, fileId); + }, +}); diff --git a/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts b/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts index b70319b81..b56b4afac 100644 --- a/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts +++ b/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts @@ -1,10 +1,21 @@ import type { SQL } from "drizzle-orm"; import type { Context } from "hono"; -import { and, asc, desc, eq, inArray, lt, lte, sql } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + inArray, + lt, + lte, + notInArray, + sql, +} from "drizzle-orm"; import type { EnvVitNode } from "@/api/middlewares/global.middleware"; +import { QueueDeferError } from "@/api/lib/queue"; import { core_queue } from "@/database/queue"; import { resolveQueueTaskOutcome } from "@/lib/api/resolve-queue-task-outcome"; @@ -12,7 +23,95 @@ const QUEUE_BATCH_SIZE = 25; const QUEUE_LOCK_KEY = "queue:process"; const QUEUE_LOCK_TTL_SECONDS = 55; const QUEUE_RETENTION_DAYS = 7; -const QUEUE_STALE_PROCESSING_MINUTES = 15; +/** A task running longer than this is presumed abandoned (its process died). */ +export const DEFAULT_QUEUE_LEASE_SECONDS = 600; + +/** + * Queues the general worker leaves alone. AI work runs on its own cron with + * its own small batch, so a slow model never holds up e-mails. + */ +export const DEDICATED_QUEUES = ["ai"] as const; + +export interface ProcessQueueOptions { + batchSize?: number; + lockKey?: string; + /** Process only these queues; omit for every queue not dedicated elsewhere. */ + queues?: string[]; +} + +/** + * Tasks stuck in `processing` past their lease go back to `pending` - the + * attempt is spent - or to `failed` when none are left. Without this, a crash + * mid-task left it `processing` forever. + */ +export const recoverAbandonedQueueTasks = async ( + c: Context, + now = new Date(), +): Promise => { + const leases = new Map( + c + .get("core") + .queue.map(task => [ + `${task.pluginId}:${task.name}`, + task.leaseSeconds ?? DEFAULT_QUEUE_LEASE_SECONDS, + ]), + ); + const shortest = Math.min(DEFAULT_QUEUE_LEASE_SECONDS, ...leases.values()); + const db = c.get("db"); + + const stuck = await db + .select({ + attempts: core_queue.attempts, + id: core_queue.id, + maxAttempts: core_queue.maxAttempts, + name: core_queue.name, + pluginId: core_queue.pluginId, + reservedAt: core_queue.reservedAt, + }) + .from(core_queue) + .where( + and( + eq(core_queue.status, "processing"), + lt(core_queue.reservedAt, new Date(now.getTime() - shortest * 1000)), + ), + ) + .limit(100); + + let recovered = 0; + for (const task of stuck) { + const lease = + leases.get(`${task.pluginId}:${task.name}`) ?? + DEFAULT_QUEUE_LEASE_SECONDS; + if ( + !task.reservedAt || + task.reservedAt.getTime() > now.getTime() - lease * 1000 + ) { + continue; + } + const exhausted = task.attempts >= task.maxAttempts; + // Conditional on the reservation we saw: a task its worker finished in + // the meantime is left alone. + const updated = await db + .update(core_queue) + .set({ + completedAt: exhausted ? now : null, + lastError: "Abandoned: the worker did not finish within its lease", + reservedAt: null, + status: exhausted ? "failed" : "pending", + }) + .where( + and( + eq(core_queue.id, task.id), + eq(core_queue.status, "processing"), + eq(core_queue.reservedAt, task.reservedAt), + ), + ) + .returning({ id: core_queue.id }); + recovered += updated.length; + } + + return recovered; +}; type QueueRow = typeof core_queue.$inferSelect; @@ -20,6 +119,7 @@ const claimQueueTasks = async ( c: Context, where: SQL | undefined, now: Date, + batchSize = QUEUE_BATCH_SIZE, ): Promise => await c.get("db").transaction(async tx => { const rows = await tx @@ -33,7 +133,7 @@ const claimQueueTasks = async ( ), ) .orderBy(desc(core_queue.priority), asc(core_queue.id)) - .limit(QUEUE_BATCH_SIZE) + .limit(batchSize) .for("update", { skipLocked: true }); if (rows.length === 0) return []; @@ -60,6 +160,7 @@ const runClaimedQueueTasks = async ( ): Promise => { if (claimed.length === 0) return; + const db = c.get("db"); const handlerMap = new Map( c.get("core").queue.map(task => [`${task.pluginId}:${task.name}`, task]), ); @@ -76,6 +177,19 @@ const runClaimedQueueTasks = async ( try { await registered.handler(c, task.payload); } catch (err) { + if (err instanceof QueueDeferError) { + await db + .update(core_queue) + .set({ + attempts: sql`GREATEST(${core_queue.attempts} - 1, 0)`, + availableAt: err.until, + lastError: err.message, + reservedAt: null, + status: "pending", + }) + .where(eq(core_queue.id, task.id)); + continue; + } error = err instanceof Error ? err.message : String(err); await c.get("log").error(`Queue task "${key}" failed: ${error}`); } @@ -87,8 +201,7 @@ const runClaimedQueueTasks = async ( error, }); - await c - .get("db") + await db .update(core_queue) .set({ status: outcome.status, @@ -101,44 +214,6 @@ const runClaimedQueueTasks = async ( } }; -const reclaimStaleQueueTasks = async ( - c: Context, - now: Date, -): Promise => { - const stale = lt( - core_queue.reservedAt, - new Date(now.getTime() - QUEUE_STALE_PROCESSING_MINUTES * 60_000), - ); - const staleTaskError = "The worker stopped before the task finished."; - const db = c.get("db"); - - await db - .update(core_queue) - .set({ - completedAt: now, - lastError: staleTaskError, - reservedAt: null, - status: "failed", - }) - .where( - and( - eq(core_queue.status, "processing"), - stale, - sql`${core_queue.attempts} >= ${core_queue.maxAttempts}`, - ), - ); - - await db - .update(core_queue) - .set({ - availableAt: now, - lastError: staleTaskError, - reservedAt: null, - status: "pending", - }) - .where(and(eq(core_queue.status, "processing"), stale)); -}; - export const processQueueTasksByIds = async ( c: Context, ids: number[], @@ -155,19 +230,33 @@ export const processQueueTasksByIds = async ( export const processQueueTasks = async ( c: Context, + { + batchSize = QUEUE_BATCH_SIZE, + lockKey = QUEUE_LOCK_KEY, + queues, + }: ProcessQueueOptions = {}, ): Promise => { const gotLock = await c .get("cache") - .acquireLock(QUEUE_LOCK_KEY, QUEUE_LOCK_TTL_SECONDS); + .acquireLock(lockKey, QUEUE_LOCK_TTL_SECONDS); if (!gotLock) return; try { const db = c.get("db"); const now = new Date(); - await reclaimStaleQueueTasks(c, now); - await runClaimedQueueTasks(c, await claimQueueTasks(c, undefined, now)); + await recoverAbandonedQueueTasks(c, now); + + const queueFilter = queues + ? inArray(core_queue.queue, queues) + : notInArray(core_queue.queue, [...DEDICATED_QUEUES]); + await runClaimedQueueTasks( + c, + await claimQueueTasks(c, queueFilter, now, batchSize), + ); + // Queue rows are a work list, never a record: AI spending and history + // live in core_ai_runs, which this cleanup does not touch. const cutoff = new Date( now.getTime() - QUEUE_RETENTION_DAYS * 24 * 60 * 60 * 1000, ); @@ -180,6 +269,6 @@ export const processQueueTasks = async ( ), ); } finally { - await c.get("cache").releaseLock(QUEUE_LOCK_KEY); + await c.get("cache").releaseLock(lockKey); } }; diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index 074212578..f9acc86db 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -4,6 +4,10 @@ import { blocks } from "@/blocks/built-in"; import { CONFIG_PLUGIN } from "@/config"; import { settingsPage } from "@/views/auth/settings/widgets/settings-page"; +import { + altGenerateAiAction, + altTranslateAiAction, +} from "./lib/ai/alt-actions"; import { buildApiPlugin } from "./lib/plugin"; import { adminModule } from "./modules/admin/admin.module"; import { aiModule } from "./modules/ai/ai.module"; @@ -18,6 +22,7 @@ import { usersModule } from "./modules/users/users.module"; export const newBuildPluginApiCore = buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, + aiActions: [altGenerateAiAction, altTranslateAiAction], blocks, editablePages: [settingsPage], navigation: [ @@ -72,6 +77,7 @@ export const newBuildPluginApiCore = buildApiPlugin({ ], files: [ "can_view", + { permission: "can_edit_alt", dependsOn: ["can_view"] }, { permission: "can_download", dependsOn: ["can_view"] }, { permission: "can_delete", dependsOn: ["can_view"] }, ], diff --git a/packages/vitnode/src/content/files.ts b/packages/vitnode/src/content/files.ts index 6aa09ca10..9fcea7149 100644 --- a/packages/vitnode/src/content/files.ts +++ b/packages/vitnode/src/content/files.ts @@ -23,6 +23,11 @@ export { } from "./file-rules"; export interface ContentFileDescriptor { + /** + * The image's default ALT text per language code (Core Files). An occurrence + * override still wins - see `resolveImageAlt`. + */ + alts?: Record; height?: number; id: number; mimeType: null | string; @@ -33,6 +38,7 @@ export interface ContentFileDescriptor { } export const zodContentFileDescriptor = z.strictObject({ + alts: z.record(z.string(), z.string()).optional(), height: z.number().int().positive().optional(), id: z.number().int().positive(), mimeType: z.string().nullable(), diff --git a/packages/vitnode/src/content/server/files.ts b/packages/vitnode/src/content/server/files.ts index e331d95fd..14eaac93a 100644 --- a/packages/vitnode/src/content/server/files.ts +++ b/packages/vitnode/src/content/server/files.ts @@ -1,6 +1,6 @@ import type { Context } from "hono"; -import { inArray } from "drizzle-orm"; +import { inArray, sql } from "drizzle-orm"; import type { StorageFileUploadResult } from "../../api/models/storage"; import type { @@ -12,7 +12,7 @@ import type { import type { AnyContentTypeDefinition, ContentFileField } from "../types"; import type { ContentDatabase } from "./service"; -import { core_files } from "../../database/files"; +import { core_files, core_files_alt } from "../../database/files"; import { parseImageDimensions } from "../../lib/api/upload"; import { CONTENT_FILE_CODES } from "../const"; import { ContentInputError } from "../errors"; @@ -48,6 +48,14 @@ export const contentFileCollectionFields = ( /** The columns a descriptor is built from. Never `key`, and never `metadata`. */ const fileSelection = { + // Default ALT texts travel with image descriptors, so every page that shows + // the image can resolve one in its own language. Folded into the same + // statement: a page of descriptors stays one round trip. + alts: sql>`( + SELECT jsonb_object_agg(${core_files_alt.languageCode}, ${core_files_alt.text}) + FROM ${core_files_alt} + WHERE ${core_files_alt.fileId} = ${core_files.id} + )`, id: core_files.id, key: core_files.key, metadata: core_files.metadata, @@ -57,6 +65,7 @@ const fileSelection = { }; interface ContentFileRow { + alts?: null | Record; id: number; key: string; metadata: null | Record; @@ -77,6 +86,9 @@ const toDescriptor = ( name: row.name, size: row.size, url: url(row.key), + ...(row.alts && row.mimeType?.startsWith("image/") + ? { alts: row.alts } + : {}), ...(dimensions ? { height: dimensions.height, width: dimensions.width } : {}), diff --git a/packages/vitnode/src/database/files.ts b/packages/vitnode/src/database/files.ts index b95580d88..c47052170 100644 --- a/packages/vitnode/src/database/files.ts +++ b/packages/vitnode/src/database/files.ts @@ -1,5 +1,8 @@ -import { camelCase, index } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { camelCase, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { core_ai_runs } from "./ai"; +import { core_languages } from "./languages"; import { core_users } from "./users"; export const core_files = camelCase.table.withRLS( @@ -16,6 +19,20 @@ export const core_files = camelCase.table.withRLS( }), pluginId: t.varchar({ length: 100 }), metadata: t.jsonb().$type>().notNull().default({}), + /** + * SHA-256 of the stored bytes, set at upload (or on first ALT analysis for + * older files). An AI description belongs to one fingerprint: a changed + * file never silently keeps an obsolete one. + */ + fingerprint: t.varchar({ length: 64 }), + /** + * `automatic` lets the ALT job describe the image, `manual` leaves it to + * people, `disabled` keeps the file away from any external AI. + */ + altPolicy: t + .varchar({ enum: ["automatic", "manual", "disabled"], length: 16 }) + .notNull() + .default("automatic"), createdAt: t.timestamp().notNull().defaultNow(), }), t => [ @@ -23,3 +40,100 @@ export const core_files = camelCase.table.withRLS( index("core_files_created_at_id_idx").on(t.createdAt, t.id), ], ); + +/** + * The default ALT text of an image in core_files, one row per language. An + * empty `text` is an intentional "no description" a person chose; a missing + * row is a description nobody wrote yet. Decorative use belongs to the + * occurrence (the article, the page), not to the file. + */ +export const core_files_alt = camelCase.table.withRLS( + "core_files_alt", + t => ({ + id: t.serial().primaryKey(), + fileId: t + .integer() + .notNull() + .references(() => core_files.id, { onDelete: "cascade" }), + languageCode: t + .varchar({ length: 32 }) + .notNull() + .references(() => core_languages.code, { onDelete: "cascade" }), + text: t.text().notNull(), + origin: t.varchar({ enum: ["ai", "human"], length: 10 }).notNull(), + /** The file fingerprint an AI text was written for. */ + fileFingerprint: t.varchar({ length: 64 }), + runId: t.integer().references(() => core_ai_runs.id, { + onDelete: "set null", + }), + updatedById: t.integer().references(() => core_users.id, { + onDelete: "set null", + }), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + t => [ + uniqueIndex("core_files_alt_file_language_unique").on( + t.fileId, + t.languageCode, + ), + ], +); + +/** + * One image-level analysis per file fingerprint - the base description every + * language is translated from. Adding a language reuses it. + */ +export const core_files_alt_analysis = camelCase.table.withRLS( + "core_files_alt_analysis", + t => ({ + id: t.serial().primaryKey(), + fileId: t + .integer() + .notNull() + .references(() => core_files.id, { onDelete: "cascade" }), + fileFingerprint: t.varchar({ length: 64 }).notNull(), + description: t.text().notNull(), + runId: t.integer().references(() => core_ai_runs.id, { + onDelete: "set null", + }), + createdAt: t.timestamp().notNull().defaultNow(), + }), + t => [ + uniqueIndex("core_files_alt_analysis_file_unique").on( + t.fileId, + t.fileFingerprint, + ), + ], +); + +/** Latest ALT processing state per file, for the AdminCP progress view. */ +export const core_files_alt_state = camelCase.table.withRLS( + "core_files_alt_state", + t => ({ + fileId: t + .integer() + .primaryKey() + .references(() => core_files.id, { onDelete: "cascade" }), + status: t + .varchar({ + enum: ["pending", "completed", "failed", "waiting_budget", "skipped"], + length: 20, + }) + .notNull(), + lastError: t.varchar({ length: 255 }), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + t => [ + index("core_files_alt_state_status_idx") + .on(t.status) + .where(sql`status <> 'completed'`), + ], +); diff --git a/packages/vitnode/src/database/queue.ts b/packages/vitnode/src/database/queue.ts index a35d1c1f7..bd2a15394 100644 --- a/packages/vitnode/src/database/queue.ts +++ b/packages/vitnode/src/database/queue.ts @@ -1,4 +1,5 @@ -import { camelCase, index } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { camelCase, index, uniqueIndex } from "drizzle-orm/pg-core"; export const core_queue = camelCase.table.withRLS( "core_queue", @@ -28,9 +29,19 @@ export const core_queue = camelCase.table.withRLS( .defaultNow() .$onUpdate(() => new Date()), completedAt: t.timestamp(), + /** + * At most one pending or running task per key: dispatching the same work + * twice (an upload and the repair sweep) queues it once. + */ + dedupeKey: t.varchar({ length: 255 }), }), t => [ index("core_queue_status_available_at_idx").on(t.status, t.availableAt), index("core_queue_created_at_id_idx").on(t.createdAt, t.id), + uniqueIndex("core_queue_dedupe_active_unique") + .on(t.pluginId, t.dedupeKey) + .where( + sql`"dedupeKey" IS NOT NULL AND status IN ('pending', 'processing')`, + ), ], ); diff --git a/packages/vitnode/src/lib/files/resolve-alt.test.ts b/packages/vitnode/src/lib/files/resolve-alt.test.ts new file mode 100644 index 000000000..b3405bee9 --- /dev/null +++ b/packages/vitnode/src/lib/files/resolve-alt.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { resolveImageAlt } from "./resolve-alt"; + +const alts = { en: "A red bicycle", pl: "Czerwony rower" }; + +describe("resolveImageAlt", () => { + it("prefers the occurrence's own text", () => { + expect( + resolveImageAlt({ alts, locale: "pl", occurrence: { alt: "Mój rower" } }), + ).toEqual({ alt: "Mój rower", source: "occurrence" }); + }); + + it("keeps a decorative occurrence empty, whatever the file says", () => { + expect( + resolveImageAlt({ alts, locale: "pl", occurrence: { decorative: true } }), + ).toEqual({ alt: "", source: "decorative" }); + }); + + it("falls back to the file's ALT in the requested language", () => { + expect( + resolveImageAlt({ alts, locale: "pl", occurrence: { alt: null } }), + ).toEqual({ + alt: "Czerwony rower", + source: "file", + }); + }); + + it("keeps an intentionally empty file ALT instead of a truthy fallback", () => { + expect( + resolveImageAlt({ + alts: { en: "A red bicycle", pl: "" }, + fallbackLocales: ["en"], + locale: "pl", + }), + ).toEqual({ alt: "", source: "file" }); + }); + + it("uses a configured fallback language, then reports the gap", () => { + expect( + resolveImageAlt({ alts, fallbackLocales: ["en"], locale: "de" }), + ).toEqual({ alt: "A red bicycle", source: "fallback" }); + expect(resolveImageAlt({ alts: {}, locale: "de" })).toEqual({ + alt: "", + source: "missing", + }); + }); +}); diff --git a/packages/vitnode/src/lib/files/resolve-alt.ts b/packages/vitnode/src/lib/files/resolve-alt.ts new file mode 100644 index 000000000..42c911499 --- /dev/null +++ b/packages/vitnode/src/lib/files/resolve-alt.ts @@ -0,0 +1,53 @@ +/** + * Which ALT text one image occurrence renders with, in order: + * + * 1. the occurrence itself - an explicit text, or decorative (`""`); + * 2. the file's default ALT in the requested language (Core Files); + * 3. the file's default ALT in a fallback language; + * 4. nothing known: `""`, flagged `missing` so editors can be told. + * + * An explicit empty value is kept as it is: a person decided the image says + * nothing, and a fallback must not overrule that. + */ +export interface ImageAltOccurrence { + /** The text this occurrence set, if any. `null`/`undefined` means "not set". */ + alt?: null | string; + /** This occurrence is decorative - screen readers skip it. */ + decorative?: boolean; +} + +export type ImageAltSource = + "decorative" | "fallback" | "file" | "missing" | "occurrence"; + +export interface ResolvedImageAlt { + alt: string; + source: ImageAltSource; +} + +export const resolveImageAlt = ({ + alts, + fallbackLocales = [], + locale, + occurrence = {}, +}: { + /** The file's default ALT per language code, from its descriptor. */ + alts?: null | Record; + fallbackLocales?: readonly string[]; + locale: string; + occurrence?: ImageAltOccurrence; +}): ResolvedImageAlt => { + if (occurrence.decorative) return { alt: "", source: "decorative" }; + const own = occurrence.alt?.trim(); + if (own) return { alt: own, source: "occurrence" }; + + if (alts && Object.hasOwn(alts, locale)) { + return { alt: alts[locale], source: "file" }; + } + for (const fallback of fallbackLocales) { + if (alts && Object.hasOwn(alts, fallback)) { + return { alt: alts[fallback], source: "fallback" }; + } + } + + return { alt: "", source: "missing" }; +}; diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index d2fcd3922..60442d06f 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -47,7 +47,8 @@ }, "alt": { "placeholder": "What the image shows", - "hint": "Read aloud by screen readers instead of the image." + "hint": "Read aloud by screen readers instead of the image.", + "file_default": "Left empty, the image's default ALT text shown above is used." }, "counter": { "title": "Search results show about {max} characters.", diff --git a/plugins/blog/src/pages/post-page.tsx b/plugins/blog/src/pages/post-page.tsx index fc4c1ac8c..042be4244 100644 --- a/plugins/blog/src/pages/post-page.tsx +++ b/plugins/blog/src/pages/post-page.tsx @@ -1,6 +1,7 @@ import { DateFormat } from "@vitnode/core/components/date-format"; import { EditorContent } from "@vitnode/core/components/ui/editor-content"; import { zodContentFileDescriptor } from "@vitnode/core/content"; +import { resolveImageAlt } from "@vitnode/core/lib/files/resolve-alt"; import { definePluginRoute, type PluginRoutePageProps, @@ -10,6 +11,7 @@ import { contentDeliveryPageHead, } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { getIntlRuntime } from "@vitnode/core/tanstack/i18n"; import { useTranslations } from "use-intl"; import { z } from "zod"; @@ -104,7 +106,16 @@ const BlogPostPage = ({ {item.coverImage ? ( {item.coverImageAlt { const { currentValue, selected, setValue } = useMultiLangField( field as MultiLangFieldProps["field"], ); + const { files } = useContentForm(); + const cover = files?.coverImage; + // The image's own default ALT (Core Files) in this language - used whenever + // the article leaves its override empty. + const fileAlt = + cover && !Array.isArray(cover) ? cover.alts?.[selected] : undefined; return ( <> @@ -167,13 +174,16 @@ export const ArticleCoverAltField = ({ field }: ItemAutoFormComponentProps) => { onChange={event => { setValue(event.target.value); }} - placeholder={t("admin.article.editor.alt.placeholder")} + placeholder={ + fileAlt?.trim() ? fileAlt : t("admin.article.editor.alt.placeholder") + } value={currentValue} />

{t("admin.article.editor.alt.hint")} + {fileAlt ? ` ${t("admin.article.editor.alt.file_default")}` : null}

From e28544b76e81db499e9d857f75b68d15247902cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:54:40 +0000 Subject: [PATCH 04/14] feat(ai): AdminCP AI pages, account AI usage, field AI assistance, Quick Ask API AdminCP gets an AI section (overview, models and pricing, actions, access and limits, history, settings) and members get /settings/ai with their points, reservations, reset date, daily limits, notices and own history. Content Engine text fields accept an `ai` option naming a registered action and its source fields; the AdminCP form shows a review-before-accept suggestion that flags stale sources and newer edits. The blog excerpt and an example plugin field use it through one shared assist route. Core registers editor rewrite and quick-ask actions with NDJSON streaming and an upper-bound estimate endpoint. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR --- apps/web/src/locales/@vitnode/core/pl.json | 565 +++++++++++++++++- .../src/api/lib/ai/alt.integration.test.ts | 2 +- .../src/api/lib/ai/available-actions.ts | 54 ++ .../vitnode/src/api/lib/ai/editor-actions.ts | 184 ++++++ packages/vitnode/src/api/lib/ai/registry.ts | 32 + packages/vitnode/src/api/lib/ai/runner.ts | 17 + .../vitnode/src/api/lib/ai/stream-response.ts | 59 ++ .../src/api/middlewares/global.middleware.ts | 4 +- packages/vitnode/src/api/models/ai.ts | 9 +- .../api/modules/admin/ai/ai.admin.module.ts | 10 + .../admin/ai/routes/assist-stream.route.ts | 84 +++ .../admin/ai/routes/available.route.ts | 32 + .../modules/admin/ai/routes/feedback.route.ts | 59 ++ .../vitnode/src/api/modules/ai/ai.module.ts | 12 +- .../api/modules/ai/routes/available.route.ts | 33 + .../modules/ai/routes/stream.route.test.ts | 182 ++++++ .../src/api/modules/ai/routes/stream.route.ts | 84 +++ packages/vitnode/src/api/plugin.ts | 11 +- .../components/ai/ai-field-assist.test.tsx | 81 +++ .../src/components/ai/ai-field-assist.tsx | 296 +++++++++ .../src/components/form/fields/input.tsx | 12 +- .../form/fields/multi-lang-language.ts | 12 + .../src/components/form/fields/textarea.tsx | 12 +- packages/vitnode/src/content/admin/spec.ts | 12 + packages/vitnode/src/content/define-fields.ts | 45 ++ packages/vitnode/src/content/define.ts | 2 + packages/vitnode/src/content/fields.ts | 3 + packages/vitnode/src/content/types.ts | 23 + packages/vitnode/src/lib/ai/assist-client.ts | 113 ++++ packages/vitnode/src/lib/ai/run-status.ts | 35 ++ .../vitnode/src/lib/ai/suggestion.test.ts | 100 ++++ packages/vitnode/src/lib/ai/suggestion.ts | 87 +++ packages/vitnode/src/locales/en.json | 565 +++++++++++++++++- .../vitnode/src/pages/admin/ai/access.tsx | 24 + .../vitnode/src/pages/admin/ai/actions.tsx | 24 + .../vitnode/src/pages/admin/ai/history.tsx | 39 ++ packages/vitnode/src/pages/admin/ai/index.tsx | 34 ++ .../vitnode/src/pages/admin/ai/models.tsx | 24 + .../vitnode/src/pages/admin/ai/settings.tsx | 24 + packages/vitnode/src/pages/settings/ai.tsx | 43 ++ packages/vitnode/src/routes.test.ts | 2 + packages/vitnode/src/routes.tsx | 54 ++ .../src/tanstack/admin/ai/access-screen.tsx | 70 +++ .../src/tanstack/admin/ai/actions-screen.tsx | 45 ++ .../src/tanstack/admin/ai/history-screen.tsx | 100 ++++ .../vitnode/src/tanstack/admin/ai/index.ts | 54 ++ .../src/tanstack/admin/ai/models-screen.tsx | 47 ++ .../src/tanstack/admin/ai/overview-screen.tsx | 97 +++ .../vitnode/src/tanstack/admin/ai/query.ts | 100 ++++ .../src/tanstack/admin/ai/route-search.ts | 131 ++++ .../vitnode/src/tanstack/admin/ai/route.tsx | 186 ++++++ .../src/tanstack/admin/ai/settings-screen.tsx | 48 ++ .../vitnode/src/tanstack/settings-ai/index.ts | 14 + .../src/tanstack/settings-ai/panel.tsx | 48 ++ .../layouts/sidebar/nav/nav-model.test.ts | 7 + .../admin/layouts/sidebar/nav/nav-model.tsx | 38 ++ .../views/content/lib/field-component.tsx | 12 +- .../views/core/ai/access/access-content.tsx | 460 ++++++++++++++ .../ai/access/role-access-form-content.tsx | 215 +++++++ .../ai/access/user-override-form-content.tsx | 140 +++++ .../core/ai/actions/action-form-content.tsx | 239 ++++++++ .../core/ai/actions/action-test-content.tsx | 139 +++++ .../views/core/ai/actions/actions-content.tsx | 230 +++++++ .../admin/views/core/ai/ai-decimal.test.ts | 21 + .../views/admin/views/core/ai/ai-decimal.ts | 13 + .../views/admin/views/core/ai/ai-labels.tsx | 110 ++++ .../views/admin/views/core/ai/ai-mutations.ts | 206 +++++++ .../admin/views/core/ai/ai-permissions.ts | 15 + .../src/views/admin/views/core/ai/ai-query.ts | 433 ++++++++++++++ .../core/ai/history/history-table-content.tsx | 332 ++++++++++ .../core/ai/history/run-detail-content.tsx | 257 ++++++++ .../core/ai/models/models-content.test.tsx | 117 ++++ .../views/core/ai/models/models-content.tsx | 342 +++++++++++ .../core/ai/models/pricing-form-content.tsx | 155 +++++ .../ai/overview/overview-content.test.tsx | 121 ++++ .../core/ai/overview/overview-content.tsx | 388 ++++++++++++ .../ai/settings/settings-form-content.tsx | 324 ++++++++++ .../auth/settings/ai/ai-history-content.tsx | 105 ++++ .../settings/ai/ai-usage-content.test.tsx | 169 ++++++ .../auth/settings/ai/ai-usage-content.tsx | 192 ++++++ .../views/auth/settings/ai/ai-usage-query.ts | 127 ++++ .../src/views/auth/settings/nav-content.tsx | 2 + .../views/auth/settings/settings-nav.test.ts | 14 + .../src/views/auth/settings/settings-nav.ts | 14 +- plugins/blog/src/content/post.ts | 5 + .../blog/src/views/admin/article/editor/ai.ts | 37 +- plugins/example/src/api/ai/actions.ts | 52 ++ plugins/example/src/config.api.ts | 2 + plugins/example/src/content/article.ts | 12 +- 89 files changed, 9056 insertions(+), 28 deletions(-) create mode 100644 packages/vitnode/src/api/lib/ai/available-actions.ts create mode 100644 packages/vitnode/src/api/lib/ai/editor-actions.ts create mode 100644 packages/vitnode/src/api/lib/ai/stream-response.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/assist-stream.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/available.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/ai/routes/feedback.route.ts create mode 100644 packages/vitnode/src/api/modules/ai/routes/available.route.ts create mode 100644 packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts create mode 100644 packages/vitnode/src/api/modules/ai/routes/stream.route.ts create mode 100644 packages/vitnode/src/components/ai/ai-field-assist.test.tsx create mode 100644 packages/vitnode/src/components/ai/ai-field-assist.tsx create mode 100644 packages/vitnode/src/lib/ai/assist-client.ts create mode 100644 packages/vitnode/src/lib/ai/run-status.ts create mode 100644 packages/vitnode/src/lib/ai/suggestion.test.ts create mode 100644 packages/vitnode/src/lib/ai/suggestion.ts create mode 100644 packages/vitnode/src/pages/admin/ai/access.tsx create mode 100644 packages/vitnode/src/pages/admin/ai/actions.tsx create mode 100644 packages/vitnode/src/pages/admin/ai/history.tsx create mode 100644 packages/vitnode/src/pages/admin/ai/index.tsx create mode 100644 packages/vitnode/src/pages/admin/ai/models.tsx create mode 100644 packages/vitnode/src/pages/admin/ai/settings.tsx create mode 100644 packages/vitnode/src/pages/settings/ai.tsx create mode 100644 packages/vitnode/src/tanstack/admin/ai/access-screen.tsx create mode 100644 packages/vitnode/src/tanstack/admin/ai/actions-screen.tsx create mode 100644 packages/vitnode/src/tanstack/admin/ai/history-screen.tsx create mode 100644 packages/vitnode/src/tanstack/admin/ai/index.ts create mode 100644 packages/vitnode/src/tanstack/admin/ai/models-screen.tsx create mode 100644 packages/vitnode/src/tanstack/admin/ai/overview-screen.tsx create mode 100644 packages/vitnode/src/tanstack/admin/ai/query.ts create mode 100644 packages/vitnode/src/tanstack/admin/ai/route-search.ts create mode 100644 packages/vitnode/src/tanstack/admin/ai/route.tsx create mode 100644 packages/vitnode/src/tanstack/admin/ai/settings-screen.tsx create mode 100644 packages/vitnode/src/tanstack/settings-ai/index.ts create mode 100644 packages/vitnode/src/tanstack/settings-ai/panel.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/access/access-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/access/role-access-form-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/access/user-override-form-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/actions/action-form-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/actions/action-test-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/actions/actions-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/ai-decimal.test.ts create mode 100644 packages/vitnode/src/views/admin/views/core/ai/ai-decimal.ts create mode 100644 packages/vitnode/src/views/admin/views/core/ai/ai-labels.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/ai-mutations.ts create mode 100644 packages/vitnode/src/views/admin/views/core/ai/ai-permissions.ts create mode 100644 packages/vitnode/src/views/admin/views/core/ai/ai-query.ts create mode 100644 packages/vitnode/src/views/admin/views/core/ai/history/history-table-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/history/run-detail-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/models/models-content.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/models/models-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/models/pricing-form-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/overview/overview-content.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/overview/overview-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/ai/settings/settings-form-content.tsx create mode 100644 packages/vitnode/src/views/auth/settings/ai/ai-history-content.tsx create mode 100644 packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx create mode 100644 packages/vitnode/src/views/auth/settings/ai/ai-usage-content.tsx create mode 100644 packages/vitnode/src/views/auth/settings/ai/ai-usage-query.ts create mode 100644 plugins/example/src/api/ai/actions.ts diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index f9e031172..76551ef0a 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -322,6 +322,15 @@ "list": "Lista użytkowników", "roles": "Role", "title": "Użytkownicy" + }, + "ai": { + "title": "AI", + "overview": "Przegląd", + "models": "Modele i ceny", + "actions": "Akcje", + "access": "Dostęp i limity", + "history": "Historia", + "settings": "Ustawienia" } }, "search": { @@ -1036,6 +1045,460 @@ "label": "Zweryfikuj adres e-mail", "success": "Adres e-mail został zweryfikowany." } + }, + "ai": { + "cost": { + "unknown": "Nieznany", + "source": { + "provider": "Podany przez dostawcę", + "estimated": "Szacunkowy", + "mixed": "Częściowo szacunkowy", + "unknown": "Koszt nieznany" + } + }, + "status": { + "reserved": "Zarezerwowano", + "running": "W toku", + "succeeded": "Sukces", + "failed": "Błąd", + "canceled": "Anulowano", + "uncertain": "Niepewny" + }, + "origin": { + "user": "Użytkownicy", + "system": "Zadania w tle" + }, + "overview": { + "title": "Przegląd AI", + "desc": "Ile kosztuje AI w tym miesiącu, na co idą pieniądze i na ile pewne są te liczby.", + "period": { + "label": "Okres", + "current": "Ten miesiąc", + "previous": "Poprzedni miesiąc" + }, + "period_range": "{start} – {end}", + "stats_label": "Najważniejsze liczby", + "disabled": { + "title": "AI jest wyłączone na całej stronie", + "desc": "Żadna funkcja AI nie zadziała, dopóki nie zostanie włączona ponownie.", + "link": "Otwórz ustawienia AI" + }, + "budget": { + "spent": "Pobrano z budżetu", + "no_limit": "Nie ustawiono miesięcznego budżetu.", + "set_limit": "Ustaw go", + "of_limit": "{spent} z {limit}", + "remaining": "Zostało {remaining}", + "reserved": "{reserved} zarezerwowano na trwające operacje" + }, + "operations": "Operacje", + "failure_rate": "{share} nieudanych", + "average_operation": "Średni koszt operacji", + "average_daily": "Około {amount} dziennie", + "tokens": "Tokeny", + "tokens_split": "{input} wej. · {output} wyj.", + "system_budget": "Zadania w tle wykorzystały {spent} z {limit}.", + "system_no_limit": "brak osobnego limitu", + "coverage": { + "title": "Na ile pewne są te liczby?", + "summary": "Koszt znany dla {share} operacji.", + "provider": "Od dostawcy", + "estimated": "Szacunkowy", + "unknown": "Nieznany", + "unknown_hint": "Operacje o nieznanym koszcie są rozliczane według rezerwacji i pomijane w średnich - nigdy nie liczą się jako 0 $. Dodaj cenę modelu, aby to naprawić.", + "all_known_hint": "Każda operacja ma koszt: podany przez dostawcę albo oszacowany z ceny modelu." + }, + "alt": { + "title": "Automatyczny tekst ALT", + "desc": "Opisy obrazów tworzone w tle wraz z tłumaczeniami. Średnie uwzględniają tylko znane koszty.", + "images": "Opisane obrazy", + "translations": "Tłumaczenia", + "per_image": "Na obraz", + "per_translation": "Na tłumaczenie" + }, + "breakdown": { + "title": "Na co idą koszty", + "by_action": "Wg akcji", + "by_model": "Wg modelu", + "by_origin": "Wg źródła", + "name": "Nazwa", + "operations": "Operacje", + "failures": "Błędy", + "known_cost": "Znany koszt", + "coverage": "Znany koszt dla", + "empty": "W tym okresie nic jeszcze nie działało." + } + }, + "settings": { + "title": "Ustawienia AI", + "desc": "Główny przełącznik, budżety i limity, w ramach których działa każda funkcja AI.", + "time_zone": "Miesięczne budżety i limity odnawiają się o północy w strefie {timeZone}.", + "tabs": { + "budget": "Budżet", + "limits": "Limity", + "alt": "Tekst ALT" + }, + "enabled": { + "label": "Funkcje AI", + "desc": "Główny przełącznik. Po wyłączeniu żadna funkcja AI nie działa - ani dla użytkowników, ani w tle." + }, + "monthly_budget": { + "label": "Miesięczny budżet strony", + "desc": "Najwięcej, ile cała strona może wydać na AI w miesiącu. Po jego wyczerpaniu wszystkie funkcje AI czekają do następnego miesiąca." + }, + "system_budget": { + "label": "Budżet zadań w tle", + "desc": "Osobny limit dla automatyzacji, np. tekstu ALT, w ramach budżetu strony." + }, + "no_limit": "Bez limitu", + "or": "lub", + "default_points": { + "label": "Domyślne punkty miesięczne", + "desc": "Tyle dostaje użytkownik co miesiąc, chyba że jego rola mówi inaczej. Jeden punkt to {usd}." + }, + "points_unit": "pkt", + "requests_per_minute": { + "label": "Żądania na minutę", + "desc": "Jak często jeden użytkownik może uruchomić funkcję AI." + }, + "user_concurrency": { + "label": "Równoległe operacje użytkownika", + "desc": "Ile funkcji AI jeden użytkownik może mieć uruchomionych naraz." + }, + "system_concurrency": { + "label": "Równoległe operacje w tle", + "desc": "Ile zadań AI w tle może działać naraz na całej stronie." + }, + "retention": { + "label": "Przechowuj historię przez", + "desc": "Starsze wpisy operacji są usuwane. Polecenia i wyniki i tak nigdy nie są zapisywane." + }, + "days_unit": "dni", + "alt": { + "enabled": { + "label": "Twórz tekst ALT automatycznie", + "desc": "Opisuj przesłane obrazy w tle. Wymaga miesięcznego budżetu strony, więc nigdy nie wyda pieniędzy bez limitu." + }, + "needs_budget": "Ustaw miesięczny budżet strony, zanim włączysz automatyczny tekst ALT.", + "languages": { + "label": "Języki", + "desc": "Kody języków oddzielone przecinkami. Pozostaw puste, aby tworzyć tekst ALT we wszystkich włączonych językach.", + "placeholder": "en, pl" + }, + "batch_size": { + "label": "Obrazów w partii", + "desc": "Ile obrazów opisuje jedno zadanie w tle." + } + }, + "submit": "Zapisz ustawienia", + "saved": { + "title": "Zapisano ustawienia AI", + "desc": "Nowe limity obowiązują od następnej operacji AI." + } + }, + "models": { + "title": "Modele i ceny", + "desc": "Modele dostępne na tej stronie, ich możliwości i koszty.", + "capabilities": "Możliwości", + "capability": { + "text": "Tekst", + "image-input": "Obrazy na wejściu", + "structured-output": "Wynik strukturalny", + "streaming": "Strumieniowanie" + }, + "source": { + "config": "Z konfiguracji", + "sync": "Z AI Gateway", + "manual": "Ręczna cena", + "none": "Brak ceny" + }, + "per_million_hint": "USD za 1 mln tokenów", + "no_price": "Bez ceny koszty tego modelu są nieznane: rozlicza się je według rezerwacji i nigdy nie pokazuje jako 0 $.", + "catalog_hint": "Ręczna cena zastępuje cenę z katalogu. Zresetuj ją, aby wrócić do katalogu.", + "updated": "Cena zaktualizowana", + "price": { + "input": "Wejście", + "output": "Wyjście", + "cache_read": "Odczyt z cache", + "cache_write": "Zapis do cache", + "per_request": "Za żądanie", + "per_image": "Za obraz", + "tiers": "{count, plural, one {# próg długiego kontekstu} few {# progi długiego kontekstu} many {# progów długiego kontekstu} other {# progu długiego kontekstu}} bez zmian" + }, + "pricing_form": { + "open": "Edytuj cenę", + "title": "Cena dla {name}", + "desc": "Wcześniejsze operacje zachowują cenę, po której je rozliczono. Ta obowiązuje od teraz.", + "input": "Wejście", + "input_desc": "Ceny podawane są w USD za milion tokenów.", + "output": "Wyjście", + "cache_read": "Odczyt z cache", + "cache_write": "Zapis do cache", + "per_request": "Stała opłata za żądanie", + "per_image": "Stała opłata za obraz", + "per_million": "USD / 1 mln tokenów", + "per_request_unit": "USD / żądanie", + "per_image_unit": "USD / obraz", + "or": "lub", + "not_billed": "Nie naliczana", + "submit": "Zapisz cenę", + "saved": { + "title": "Zapisano cenę", + "desc": "{name} używa teraz Twojej ceny." + } + }, + "reset": { + "open": "Resetuj", + "title": "Usunąć ręczną cenę?", + "desc": "{name} wróci do ceny z katalogu. Rozliczone operacje zachowają swoją cenę.", + "cancel": "Anuluj", + "confirm": "Usuń ręczną cenę", + "success": "Usunięto ręczną cenę", + "success_desc": "{name} znów używa ceny z katalogu." + }, + "sync": { + "button": "Synchronizuj ceny z bramki", + "success": "Zsynchronizowano ceny", + "success_desc": "Zaktualizowano: {updated}, bez zmian: {unchanged}, bez opublikowanej ceny: {unpriced}.", + "error": "Nie udało się zsynchronizować cen", + "error_desc": "Nie udało się odczytać cennika AI Gateway. Spróbuj ponownie za chwilę." + }, + "empty": { + "title": "Brak skonfigurowanych modeli AI", + "desc": "Dodaj model w sekcji AI konfiguracji VitNode, aby zacząć." + } + }, + "actions": { + "title": "Akcje AI", + "desc": "Każda funkcja AI zarejestrowana przez wtyczki: włączaj je, wybieraj model i ustawiaj limity.", + "enabled": "Wł.", + "disabled": "Wył.", + "model": "Model", + "model_default": "Domyślny strony", + "daily_limit": "Dzienny limit", + "no_limit": "Brak", + "actors": "Używają", + "compatible": "Zgodne modele", + "no_compatible": "Brak - nie może działać", + "empty": { + "title": "Brak akcji AI", + "desc": "Akcje AI rejestrują wtyczki; gdy któraś to zrobi, pojawi się tutaj." + }, + "form": { + "open": "Konfiguruj", + "title": "Konfiguracja akcji", + "tabs": { + "general": "Ogólne", + "limits": "Limity" + }, + "enabled": "Włączona", + "enabled_desc": "Wyłączona akcja nie zadziała dla nikogo.", + "model": "Model", + "model_desc": "Widoczne są tylko modele mające wszystkie możliwości wymagane przez akcję.", + "model_default": "Domyślny strony", + "fallback": "Model zapasowy", + "fallback_desc": "Używany, gdy główny model zawiedzie.", + "instructions": "Wytyczne redakcyjne", + "instructions_desc": "Dodatkowe wskazówki dołączane do polecenia, np. ton albo styl. Do 2000 znaków.", + "daily_limit": "Użycia na użytkownika dziennie", + "max_input_characters": "Maks. znaków na wejściu", + "max_output_tokens": "Maks. tokenów wyjścia", + "max_retries": "Maks. ponowień", + "max_steps": "Maks. kroków", + "timeout": "Limit czasu", + "or": "lub", + "use_default": "Domyślnie ({value})", + "use_default_none": "Domyślnie (bez limitu)", + "submit": "Zapisz akcję", + "saved": { + "title": "Zapisano akcję", + "desc": "{key} użyje nowych ustawień od następnego uruchomienia." + } + }, + "test": { + "open": "Testuj", + "title": "Uruchomienie testowe", + "billed": { + "title": "To prawdziwe, płatne uruchomienie", + "desc": "Przechodzi przez te same limity i rozliczenia co każda inna operacja, pobiera Twoje punkty i trafia do historii." + }, + "input": "Dane wejściowe (JSON)", + "input_desc": "Dane wejściowe akcji, dokładnie takie, jakie wysłałaby wtyczka.", + "invalid_json": "To nie jest poprawny JSON.", + "submit": "Uruchom test", + "result": "Wynik", + "charged": "Pobrano {points} pkt", + "cost_unknown": "Koszt nieznany", + "run_id": "Operacja #{id} w historii.", + "failed": "Test się nie powiódł", + "done": { + "title": "Test zakończony", + "desc": "Zapisano jako operację #{id} w historii AI." + } + } + }, + "access": { + "title": "Dostęp i limity", + "desc": "Kto może używać których funkcji AI i w jakim zakresie.", + "unlimited": "Bez limitu", + "site_default": "Domyślne strony", + "points_month": "{points} pkt / miesiąc", + "layers": { + "title": "Cztery niezależne ustawienia", + "permission": { + "title": "Uprawnienie", + "desc": "Czy rola w ogóle może używać danej funkcji." + }, + "points": { + "title": "Punkty miesięczne", + "desc": "Ile użytkownik może wydać w miesiącu na wszystkie funkcje." + }, + "daily": { + "title": "Dzienna liczba", + "desc": "Ile razy dziennie jedna funkcja może działać dla użytkownika." + }, + "budget": { + "title": "Wydatki strony", + "desc": "Limit dla całej strony w USD.", + "link": "Zmień go w ustawieniach" + } + }, + "roles": { + "title": "Role", + "desc": "Użytkownik dostaje najhojniejszy limit i najszersze uprawnienia ze wszystkich swoich ról.", + "no_features": "Nie zarejestrowano jeszcze funkcji AI dla użytkowników, więc nie ma czego przyznawać.", + "root": "Root", + "features_allowed": "Dozwolone funkcje: {allowed} z {total}", + "edit": "Edytuj dostęp", + "edit_title": "Dostęp do AI dla roli {name}", + "edit_desc": "Punkty miesięczne i funkcje AI dostępne dla tej roli." + }, + "role_form": { + "tabs": { + "allowance": "Limit", + "features": "Funkcje" + }, + "unlimited": "Punkty bez limitu", + "unlimited_desc": "Użytkowników z tą rolą nie zatrzyma miesięczny limit. Budżet strony nadal obowiązuje.", + "monthly_points": "Punkty miesięczne", + "monthly_points_desc": "Punkty, które co miesiąc dostaje każdy użytkownik z tą rolą.", + "site_default": "Domyślne strony", + "points_unit": "pkt", + "or": "lub", + "grant": { + "default_allowed": "Domyślnie (dozwolone)", + "default_denied": "Domyślnie (niedozwolone)", + "allow": "Zezwól", + "deny": "Zabroń" + }, + "daily_limit": "Dzienny limit", + "action_default": "Limit akcji", + "per_day": "użyć / dzień", + "submit": "Zapisz dostęp", + "saved": { + "title": "Zapisano dostęp", + "desc": "Rola {name} ma nowy dostęp do AI." + } + }, + "users": { + "title": "Wyjątki użytkowników", + "desc": "Własna reguła jednego użytkownika, ważniejsza niż jego role: zablokuj go, znieś limit albo ustaw inny przydział.", + "add": "Dodaj wyjątek", + "add_title": "Dodaj wyjątek", + "edit": "Edytuj wyjątek", + "edit_title": "Wyjątek dla {name}", + "form_desc": "Zastępuje to, co dają role użytkownika, dopóki nie zostanie usunięty.", + "empty": "Brak wyjątków - wszyscy korzystają z ustawień ról.", + "blocked": "Zablokowany", + "remove": { + "title": "Usuń wyjątek", + "desc": "{name} wróci do tego, na co pozwalają role.", + "cancel": "Anuluj", + "confirm": "Usuń", + "success": "Usunięto wyjątek", + "success_desc": "{name} znów korzysta z ustawień ról." + } + }, + "user_form": { + "user": "Użytkownik", + "user_required": "Wybierz użytkownika.", + "blocked": "Zablokuj funkcje AI", + "blocked_desc": "Ten użytkownik nie może używać żadnej funkcji AI, niezależnie od ról.", + "unlimited": "Punkty bez limitu", + "unlimited_desc": "Nie zatrzyma go miesięczny limit. Budżet strony nadal obowiązuje.", + "monthly_points": "Punkty miesięczne", + "monthly_points_desc": "Osobisty przydział zamiast tego z ról.", + "from_roles": "Według ról", + "points_unit": "pkt", + "or": "lub", + "submit": "Zapisz wyjątek", + "saved": { + "title": "Zapisano wyjątek", + "desc": "Obowiązuje od następnej operacji AI użytkownika." + } + } + }, + "history": { + "title": "Historia AI", + "desc": "Każda operacja AI: kto ją uruchomił, który model odpowiedział i ile to kosztowało.", + "charged_reservation": "Pobrano {amount} (rezerwacja)", + "filters": { + "all": "Wszystkie", + "status": "Status", + "actor": "Źródło", + "action": "Akcja", + "model": "Model" + }, + "list": { + "action": "Akcja", + "status": "Status", + "actor": "Kto", + "model": "Model", + "cost": "Koszt", + "points": "Punkty", + "created": "Kiedy", + "details": "Szczegóły" + }, + "empty": { + "title": "Brak operacji AI", + "desc": "Nic jeszcze nie pasuje do tych filtrów." + }, + "detail": { + "title": "Operacja #{id}", + "status": "Status", + "actor": "Kto", + "model": "Model", + "cost": "Koszt", + "cost_source": "Źródło kosztu", + "charged": "Pobrano", + "reserved": "Zarezerwowano", + "points_unit": "pkt", + "settlement": "Rozliczenie", + "tokens": "Tokeny wej. / wyj.", + "created": "Start", + "finished": "Koniec", + "prompt_version": "Wersja polecenia", + "resource": "Zasób", + "error": "Błąd", + "accepted": "Sugestia", + "accepted_yes": "Przyjęta", + "accepted_no": "Nieprzyjęta", + "privacy": "Polecenia i wyniki nigdy nie są zapisywane - tylko to, ile to trwało i kosztowało.", + "calls": { + "title": "Wywołania dostawcy", + "empty": "Nie wywołano żadnego dostawcy.", + "attempt": "Próba", + "model": "Model", + "status": "Status", + "tokens": "Tokeny wej. / wyj.", + "cost": "Koszt" + }, + "adjustments": { + "title": "Korekty kosztu", + "change": "{from} → {to}" + } + } + } } }, "core": { @@ -1099,7 +1562,8 @@ "overview": "Przegląd", "security": "Bezpieczeństwo", "sso": "Połączone konta", - "notifications": "Powiadomienia" + "notifications": "Powiadomienia", + "ai": "Użycie AI" }, "overview": { "accountTitle": "Konto", @@ -1412,6 +1876,62 @@ "daily": "Podsumowanie dzienne", "weekly": "Podsumowanie tygodniowe" } + }, + "ai": { + "title": "Użycie AI", + "desc": "Twoje miesięczne punkty AI, dzienne limity funkcji i ostatnie operacje.", + "notice": { + "disabled": { + "title": "Funkcje AI są wyłączone", + "desc": "Ta strona nie oferuje teraz funkcji AI. Twoje punkty poczekają, aż wrócą." + }, + "site_paused": { + "title": "Funkcje AI są wstrzymane dla wszystkich", + "desc": "Strona wykorzystała miesięczny budżet AI. To nie kwestia Twojego limitu - funkcje wrócą po odnowieniu budżetu albo gdy administrator go zwiększy." + }, + "exhausted": { + "title": "Wykorzystano wszystkie punkty AI", + "desc": "Twój limit odnowi się {resetsAt}. Do tego czasu funkcje AI nie zadziałają na Twoim koncie." + }, + "near_limit": { + "title": "Wykorzystano ponad 80% punktów AI", + "desc": "Gospodaruj resztą rozważnie - limit odnowi się {resetsAt}." + } + }, + "points": { + "title": "Miesięczny limit", + "footer": "Punkty są rezerwowane na czas działania funkcji AI, a pobierany jest tylko rzeczywisty koszt. Niewykorzystane punkty nie przechodzą na kolejny miesiąc.", + "used": "Wykorzystane punkty", + "used_of": "{used} z {total} pkt", + "used_unlimited": "{used} pkt (bez limitu)", + "available": "Dostępne", + "reserved": "Zarezerwowane", + "resets": "Odnowienie", + "unlimited": "Bez limitu" + }, + "daily": { + "title": "Dzienne limity funkcji", + "footer": "Niektóre funkcje ograniczają też liczbę użyć dziennie, niezależnie od punktów.", + "used_of": "{used} z {limit} dzisiaj", + "no_limit": "{used} dzisiaj, bez dziennego limitu", + "empty": "Na Twoim koncie nie ma dostępnych funkcji AI." + }, + "history": { + "title": "Ostatnie operacje", + "footer": "Widać tu tylko Twoje operacje. Polecenia i wygenerowane teksty nigdy nie są zapisywane.", + "empty": "Nie korzystano jeszcze z funkcji AI.", + "points": "{points} pkt", + "no_charge": "Bez opłaty", + "load_more": "Wczytaj więcej" + }, + "status": { + "reserved": "W kolejce", + "running": "W toku", + "succeeded": "Gotowe", + "failed": "Błąd", + "canceled": "Anulowano", + "uncertain": "Weryfikacja" + } } }, "sign_in": { @@ -2155,6 +2675,47 @@ "empty_title": "Na razie pusto", "empty_unread_title": "Brak nieprzeczytanych powiadomień", "empty_desc": "Gdy coś będzie wymagało Twojej uwagi, pojawi się tutaj." + }, + "ai_assist": { + "button": "AI", + "open": "Sugestia AI dla pola {field}", + "title": "Sugestia AI", + "desc": "Napisana na podstawie innych pól w języku {language}. Sprawdź ją przed użyciem.", + "missing_sources": "Najpierw uzupełnij: {fields}.", + "generating": "Piszę sugestię…", + "generate": "Wygeneruj", + "regenerate": "Spróbuj ponownie", + "discard": "Odrzuć", + "accept": "Użyj sugestii", + "replace_anyway": "Zastąp mój nowszy tekst", + "stale_source": "Tekst źródłowy zmienił się od tego czasu. Wygeneruj ponownie, aby mieć aktualną sugestię.", + "target_edited": "Pole zostało zmienione po wysłaniu prośby. Użycie sugestii zastąpi nowszy tekst.", + "accepted": { + "title": "Dodano sugestię", + "desc": "Zapisz formularz, aby ją zachować." + }, + "error": { + "title": "Brak sugestii AI", + "AI_ACTION_DISABLED": "Administrator wyłączył tę funkcję AI.", + "AI_BUDGET_EXHAUSTED": "Budżet AI strony został chwilowo wyczerpany. To nie jest Twój limit.", + "AI_CANCELED": "Żądanie zostało anulowane.", + "AI_CONCURRENCY_LIMITED": "Inne Twoje żądanie AI jeszcze trwa. Spróbuj za chwilę.", + "AI_DAILY_LIMIT_REACHED": "Osiągnięto dzisiejszy limit tej funkcji AI.", + "AI_DISABLED": "Funkcje AI są wyłączone na tej stronie.", + "AI_INPUT_TOO_LARGE": "Tekst jest za długi dla tej funkcji AI.", + "AI_INVALID_INPUT": "Brakuje części tekstu źródłowego lub jest niepoprawna.", + "AI_INVALID_OUTPUT": "Odpowiedź AI nie nadawała się do użycia. Nic nie zostało pobrane.", + "AI_MODEL_INCOMPATIBLE": "Żaden skonfigurowany model AI jeszcze tego nie potrafi.", + "AI_NOT_CONFIGURED": "Nie skonfigurowano modelu AI.", + "AI_PRICING_MISSING": "Model AI nie ma cennika, więc nie można ograniczyć kosztu.", + "AI_PROVIDER_FAILED": "Dostawca AI zawiódł. Nic nie zostało pobrane.", + "AI_RATE_LIMITED": "Zbyt wiele żądań AI. Spróbuj za minutę.", + "AI_TIMEOUT": "Dostawca AI odpowiadał zbyt długo. Nic nie zostało pobrane.", + "AI_UNAUTHORIZED": "Nie masz dostępu do tej funkcji AI lub tej treści.", + "AI_USER_LIMIT_REACHED": "Wykorzystano wszystkie punkty AI w tym miesiącu.", + "unknown": "Coś poszło nie tak. Spróbuj ponownie." + } } - } + }, + "@vitnode/core:files:can_edit_alt": "Edycja tekstu alternatywnego obrazów" } diff --git a/packages/vitnode/src/api/lib/ai/alt.integration.test.ts b/packages/vitnode/src/api/lib/ai/alt.integration.test.ts index 1db994736..ae4dc8148 100644 --- a/packages/vitnode/src/api/lib/ai/alt.integration.test.ts +++ b/packages/vitnode/src/api/lib/ai/alt.integration.test.ts @@ -353,7 +353,7 @@ describePostgres("automatic ALT (real PostgreSQL)", () => { it("sweeps in bounded batches, skips opted-out files and wraps around", async () => { await database.db.delete(core_queue); await database.db.delete(core_files); - const ids = []; + const ids: number[] = []; for (let index = 0; index < 3; index++) ids.push(await insertFile()); await insertFile({ altPolicy: "disabled" }); await insertFile({ mimeType: "application/pdf" }); diff --git a/packages/vitnode/src/api/lib/ai/available-actions.ts b/packages/vitnode/src/api/lib/ai/available-actions.ts new file mode 100644 index 000000000..27a0eb0f4 --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/available-actions.ts @@ -0,0 +1,54 @@ +import type { Context } from "hono"; + +import { + DEFAULT_AI_MODEL_CAPABILITIES, + missingCapabilities, +} from "./capabilities"; + +/** + * The AI actions the signed-in person may start right now: the action is + * enabled, a configured model can run it, and their roles grant its + * permission. Content access is still checked per run - this only decides + * which buttons to show. + */ +export const availableAiActions = async ( + c: Context, + userId: number, +): Promise => { + const ai = c.get("ai"); + const ledger = ai.ledger(); + const settings = await ledger.loadSettings(); + if (!settings.enabled) return []; + const models = c.get("core").ai?.models ?? []; + + const available: string[] = []; + const policies = new Map(); + for (const action of ai.actions().all()) { + if (!action.definition.actors.includes("user")) continue; + const runnable = models.some( + model => + missingCapabilities( + action.definition.requiredCapabilities, + model.capabilities ?? DEFAULT_AI_MODEL_CAPABILITIES, + ).length === 0, + ); + if (!runnable) continue; + const actionSettings = await ledger.loadActionSettings(action.key); + if (actionSettings?.enabled === false) continue; + + let granted = policies.get(action.permissionKey); + if (granted === undefined) { + granted = ( + await ledger.resolveUserPolicy({ + defaultGranted: action.definition.permission.defaultGranted, + permissionKey: action.permissionKey, + userId, + }) + ).granted; + policies.set(action.permissionKey, granted); + } + if (granted) available.push(action.key); + } + + return available; +}; diff --git a/packages/vitnode/src/api/lib/ai/editor-actions.ts b/packages/vitnode/src/api/lib/ai/editor-actions.ts new file mode 100644 index 000000000..bcf0eb8ad --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/editor-actions.ts @@ -0,0 +1,184 @@ +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/config"; + +import { defineAiAction } from "./action"; +import { aiActionRef } from "./registry"; + +/** Bounds of what the editor sends: the selection and a little context. */ +export const QUICK_ASK_LIMITS = { + context: 1_500, + instruction: 500, + selection: 8_000, +} as const; + +export const QUICK_ASK_OPERATIONS = [ + "shorten", + "correct", + "simplify", + "tone", +] as const; +export const QUICK_ASK_TONES = [ + "confident", + "formal", + "friendly", + "neutral", +] as const; + +const languageName = (code: string) => { + try { + return new Intl.DisplayNames(["en"], { type: "language" }).of(code) ?? code; + } catch { + return code; + } +}; + +const PLAIN_TEXT_RULES = [ + "Answer with plain text only: no Markdown, no HTML, no quotes, no notes.", + "Separate paragraphs with one blank line.", +]; + +const contextBlock = (input: { after: string; before: string }) => + [ + input.before + ? `Text before (context only, do not repeat):\n${input.before}` + : "", + input.after + ? `Text after (context only, do not repeat):\n${input.after}` + : "", + ] + .filter(Boolean) + .join("\n\n"); + +const OPERATION_INSTRUCTIONS: Record< + (typeof QUICK_ASK_OPERATIONS)[number], + string +> = { + correct: + "Correct spelling, grammar and punctuation. Change nothing else - keep the wording, meaning and style.", + shorten: "Make the text shorter and tighter while keeping its meaning.", + simplify: + "Rewrite the text in simpler words and shorter sentences, for a broad audience.", + tone: "Rewrite the text in the requested tone, keeping its meaning.", +}; + +/** + * The text a person typed in their own editor is the only input: nothing is + * read from the server, so there is no content to authorize beyond being + * signed in - and the input is bounded. + */ +const ownDraft = () => true; + +/** Shorten, correct, simplify or change the tone of a selection. */ +export const editorRewriteAiAction = defineAiAction({ + authorize: ownDraft, + buildPrompt: (input, { instructions }) => ({ + prompt: [contextBlock(input), `Text to rewrite:\n${input.selection}`] + .filter(Boolean) + .join("\n\n"), + system: [ + "You edit a passage of text inside a content editor.", + OPERATION_INSTRUCTIONS[input.operation], + ...(input.operation === "tone" && input.tone + ? [`Tone: ${input.tone}.`] + : []), + `Write in ${languageName(input.locale)}.`, + "Answer with the rewritten passage only.", + ...PLAIN_TEXT_RULES, + ...(instructions ? [instructions] : []), + ].join("\n"), + }), + defaults: { + maxInputCharacters: + QUICK_ASK_LIMITS.selection + 2 * QUICK_ASK_LIMITS.context, + maxOutputTokens: 2_000, + timeoutMs: 60_000, + }, + description: + "Rewrites the selected text: shorter, corrected, simpler or in another tone.", + id: "editor.selection.rewrite", + inputSchema: z.object({ + after: z.string().max(QUICK_ASK_LIMITS.context), + before: z.string().max(QUICK_ASK_LIMITS.context), + locale: z.string().min(2).max(16), + operation: z.enum(QUICK_ASK_OPERATIONS), + selection: z.string().trim().min(1).max(QUICK_ASK_LIMITS.selection), + tone: z.enum(QUICK_ASK_TONES).optional(), + }), + output: "text", + outputSchema: z.string().min(1).max(40_000), + parseText: text => text.trim(), + permission: { defaultGranted: false, key: "editor.assist" }, + promptVersion: 1, + requiredCapabilities: ["text"], +}); + +/** Continue the text, or follow a custom instruction about the selection. */ +export const editorQuickAskAiAction = defineAiAction({ + authorize: ownDraft, + buildPrompt: (input, { instructions }) => ({ + prompt: [ + contextBlock(input), + input.selection ? `Selected text:\n${input.selection}` : "", + input.mode === "custom" ? `Request: ${input.instruction ?? ""}` : "", + ] + .filter(Boolean) + .join("\n\n"), + system: [ + "You help a writer inside a content editor.", + input.mode === "continue" + ? "Continue the text naturally from where the context before ends, in the same style. Write one or two paragraphs." + : "Do what the request asks with the selected text, and answer with the text to put into the document.", + `Write in ${languageName(input.locale)}.`, + "Never claim facts you cannot know; never invent quotes, statistics or sources.", + ...PLAIN_TEXT_RULES, + ...(instructions ? [instructions] : []), + ].join("\n"), + }), + defaults: { + maxInputCharacters: + QUICK_ASK_LIMITS.selection + + 2 * QUICK_ASK_LIMITS.context + + QUICK_ASK_LIMITS.instruction, + maxOutputTokens: 1_500, + timeoutMs: 60_000, + }, + description: + "Continues the text or follows a custom request about the selection.", + id: "editor.quick-ask", + inputSchema: z + .object({ + after: z.string().max(QUICK_ASK_LIMITS.context), + before: z.string().max(QUICK_ASK_LIMITS.context), + instruction: z + .string() + .trim() + .max(QUICK_ASK_LIMITS.instruction) + .optional(), + locale: z.string().min(2).max(16), + mode: z.enum(["continue", "custom"]), + selection: z.string().max(QUICK_ASK_LIMITS.selection), + }) + .refine( + input => + input.mode === "continue" + ? input.before.trim().length > 0 || input.selection.trim().length > 0 + : (input.instruction ?? "").length > 0, + "A custom request needs an instruction; continuing needs some text.", + ), + output: "text", + outputSchema: z.string().min(1).max(40_000), + parseText: text => text.trim(), + permission: { defaultGranted: false, key: "editor.assist" }, + promptVersion: 1, + requiredCapabilities: ["text"], +}); + +export const EDITOR_REWRITE_AI_ACTION = aiActionRef( + CONFIG_PLUGIN.pluginId, + editorRewriteAiAction, +); +export const EDITOR_QUICK_ASK_AI_ACTION = aiActionRef( + CONFIG_PLUGIN.pluginId, + editorQuickAskAiAction, +); diff --git a/packages/vitnode/src/api/lib/ai/registry.ts b/packages/vitnode/src/api/lib/ai/registry.ts index a6db6e489..4356298ee 100644 --- a/packages/vitnode/src/api/lib/ai/registry.ts +++ b/packages/vitnode/src/api/lib/ai/registry.ts @@ -176,3 +176,35 @@ export const collectAiActions = ( return new AiActionRegistry(registered); }; + +/** + * Every content field that asks for AI assistance names an action that + * exists and can run for users. Checked at boot, when both registries are + * complete - a typo fails the start, not the first editor who clicks. + */ +export const assertContentAiActions = ( + contentTypes: { + definition: { + fields: Record; + id: string; + }; + }[], + registry: AiActionRegistry, +): void => { + for (const { definition } of contentTypes) { + for (const [name, field] of Object.entries(definition.fields)) { + if (!field.ai) continue; + const action = registry.find(field.ai.action); + if (!action) { + throw new AiActionDefinitionError( + `Content type "${definition.id}" field "${name}" uses AI action "${field.ai.action}", which no installed plugin registers.`, + ); + } + if (!action.definition.actors.includes("user")) { + throw new AiActionDefinitionError( + `Content type "${definition.id}" field "${name}" uses AI action "${field.ai.action}", which only the system may run.`, + ); + } + } + } +}; diff --git a/packages/vitnode/src/api/lib/ai/runner.ts b/packages/vitnode/src/api/lib/ai/runner.ts index 6e42c24bd..503fe1d48 100644 --- a/packages/vitnode/src/api/lib/ai/runner.ts +++ b/packages/vitnode/src/api/lib/ai/runner.ts @@ -1025,6 +1025,23 @@ export class AiRunner { return parsed.success ? { ok: true, output: parsed.data } : { ok: false }; } + /** + * The most a run could cost the signed-in user, before running it - the + * same upper bound the reservation would hold. A bound, never a price: + * the real charge is usually far lower. `null` when no pricing bounds it. + */ + async estimate( + request: AiRunRequest, + ): Promise<{ maxPoints: null | string; maxUsd: null | string }> { + const plan = await this.prepare(request, this.currentUser()); + const usd = this.reservationUsd(plan); + + return { + maxPoints: usd === null ? null : formatDecimal(usdToPoints(usd)), + maxUsd: usd === null ? null : formatDecimal(usd), + }; + } + /** Runs an action for the signed-in user (AdminCP session first). */ async run(request: AiRunRequest): Promise> { return await this.execute(request, this.currentUser()); diff --git a/packages/vitnode/src/api/lib/ai/stream-response.ts b/packages/vitnode/src/api/lib/ai/stream-response.ts new file mode 100644 index 000000000..3891118bd --- /dev/null +++ b/packages/vitnode/src/api/lib/ai/stream-response.ts @@ -0,0 +1,59 @@ +import type { Context } from "hono"; + +import type { AiRunRequest } from "./runner"; + +import { isAiError } from "./errors"; + +/** + * Streams an AI action as newline-delimited JSON: + * `{"t":"…"}` per text delta, then `{"done":{…}}`, or `{"e":{code,message}}`. + * Limits and budgets are checked before the first byte, so those failures + * are ordinary JSON error responses. When the client disconnects, the + * provider call is canceled and the user is not charged. + */ +export const AI_NDJSON_HEADERS = { + "Content-Type": "application/x-ndjson; charset=utf-8", +} as const; + +export const aiNdjsonStream = async ( + c: Context, + request: Omit, +): Promise> => { + const { result, textStream } = await c + .get("ai") + .stream({ ...request, signal: c.req.raw.signal }); + const encoder = new TextEncoder(); + const line = (value: unknown) => encoder.encode(`${JSON.stringify(value)}\n`); + const reader = textStream.getReader(); + + return new ReadableStream({ + cancel: async reason => { + await reader.cancel(reason); + }, + pull: async controller => { + try { + const { done, value } = await reader.read(); + if (!done) { + controller.enqueue(line({ t: value })); + + return; + } + const { runId, usage } = await result; + controller.enqueue(line({ done: { runId, ...usage } })); + controller.close(); + } catch (error) { + controller.enqueue( + line({ + e: isAiError(error) + ? { code: error.code, message: error.message } + : { + code: "AI_PROVIDER_FAILED", + message: "The AI request failed.", + }, + }), + ); + controller.close(); + } + }, + }); +}; diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 06e8e4e3c..28f4c24c1 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -57,7 +57,7 @@ import type { } from "../models/search"; import type { SSOApiPlugin } from "../models/sso"; -import { collectAiActions } from "../lib/ai/registry"; +import { assertContentAiActions, collectAiActions } from "../lib/ai/registry"; import { resolveClientIp } from "../lib/client-ip"; import { collectCronJobs } from "../lib/cron"; import { @@ -385,6 +385,8 @@ export const globalMiddleware = ({ ), ); + assertContentAiActions(contentTypesMetadata, aiActionsMetadata); + const navigationMetadata: NavigationPreset[] = collectNavigationPresets(plugins); diff --git a/packages/vitnode/src/api/models/ai.ts b/packages/vitnode/src/api/models/ai.ts index 07a777c62..1cd899b0a 100644 --- a/packages/vitnode/src/api/models/ai.ts +++ b/packages/vitnode/src/api/models/ai.ts @@ -177,6 +177,13 @@ export class AIModel { return found.model; } + /** The upper bound a run could cost the signed-in user, without running it. */ + async estimate( + request: AiRunRequest, + ): Promise<{ maxPoints: null | string; maxUsd: null | string }> { + return await this.runner().estimate(request); + } + /** * Resolve an image model to pass into `generateImage`. Omit `id` for the * first configured image model. @@ -223,7 +230,6 @@ export class AIModel { provider: providerIdOf(entry.model), })); } - /** * Runs a registered action for the signed-in user through the shared * runner: authorization, limits, accounting and validation included. @@ -235,7 +241,6 @@ export class AIModel { async run(request: AiRunRequest): Promise> { return await this.runner().run(request); } - /** Runs an action as the trusted system actor. Server code only. */ async runAsSystem( request: TypedAiRunRequest, diff --git a/packages/vitnode/src/api/modules/admin/ai/ai.admin.module.ts b/packages/vitnode/src/api/modules/admin/ai/ai.admin.module.ts index 10f5d4c23..719ebc845 100644 --- a/packages/vitnode/src/api/modules/admin/ai/ai.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/ai/ai.admin.module.ts @@ -9,7 +9,13 @@ import { } from "./routes/access.route"; import { listAiActionsAdminRoute } from "./routes/actions.route"; import { getAltStatusAdminRoute, sweepAltAdminRoute } from "./routes/alt.route"; +import { + assistStreamAiAdminRoute, + estimateAiAdminRoute, +} from "./routes/assist-stream.route"; import { assistAiAdminRoute } from "./routes/assist.route"; +import { availableAiActionsAdminRoute } from "./routes/available.route"; +import { aiFeedbackAdminRoute } from "./routes/feedback.route"; import { listAiHistoryAdminRoute, showAiRunAdminRoute, @@ -47,6 +53,10 @@ export const aiAdminModule = buildModule({ listAiHistoryAdminRoute, showAiRunAdminRoute, assistAiAdminRoute, + assistStreamAiAdminRoute, + estimateAiAdminRoute, + availableAiActionsAdminRoute, + aiFeedbackAdminRoute, getAltStatusAdminRoute, sweepAltAdminRoute, ], diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/assist-stream.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/assist-stream.route.ts new file mode 100644 index 000000000..fe5c1ae44 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/assist-stream.route.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; + +import { zodAiRunBody } from "@/api/lib/ai/run-schemas"; +import { + AI_NDJSON_HEADERS, + aiNdjsonStream, +} from "@/api/lib/ai/stream-response"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +/** Streams a text action for the signed-in admin (the editor's Quick Ask). */ +export const assistStreamAiAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Stream a registered AI text action for the signed-in admin.", + path: "/assist/stream", + request: { + body: { + required: true, + content: { "application/json": { schema: zodAiRunBody } }, + }, + }, + responses: { + 200: { + content: { "application/x-ndjson": { schema: z.string() } }, + description: "Text deltas, then the run summary", + }, + }, + }, + handler: async c => { + const body = c.req.valid("json"); + + const stream = await aiNdjsonStream(c, { + ...body, + resource: body.resource ?? undefined, + }); + + // A streamed body has no typed JSON response for the OpenAPI handler type + // to check; limits were already enforced before the first byte. + return new Response(stream, { + headers: AI_NDJSON_HEADERS, + status: 200, + }) as never; + }, +}); + +export const estimateAiAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: + "The most a run could cost you, before running it. An upper bound, not a price.", + path: "/assist/estimate", + request: { + body: { + required: true, + content: { "application/json": { schema: zodAiRunBody } }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + maxPoints: z.string().nullable(), + maxUsd: z.string().nullable(), + }), + }, + }, + description: "Upper bound", + }, + }, + }, + handler: async c => { + const body = c.req.valid("json"); + const estimate = await c.get("ai").estimate({ + ...body, + resource: body.resource ?? undefined, + }); + + return c.json(estimate, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/available.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/available.route.ts new file mode 100644 index 000000000..dfba83838 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/available.route.ts @@ -0,0 +1,32 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { availableAiActions } from "@/api/lib/ai/available-actions"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +/** Which AI actions the signed-in admin may start - for showing AI buttons. */ +export const availableAiActionsAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: "The AI actions you may start in the AdminCP.", + path: "/assist/available", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ actions: z.array(z.string()) }), + }, + }, + description: "Available action keys", + }, + }, + }, + handler: async c => { + const userId = c.get("admin")?.user.id; + if (!userId) throw new HTTPException(403); + + return c.json({ actions: await availableAiActions(c, userId) }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/feedback.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/feedback.route.ts new file mode 100644 index 000000000..30fb36720 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/ai/routes/feedback.route.ts @@ -0,0 +1,59 @@ +import { and, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_ai_runs } from "@/database/ai"; + +/** The AdminCP twin of `POST /ai/runs/{id}/feedback`. Tracking only. */ +export const aiFeedbackAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Record whether you accepted an AI suggestion.", + path: "/assist/runs/{id}/feedback", + request: { + params: z.object({ id: z.coerce.number().int() }), + body: { + required: true, + content: { + "application/json": { schema: z.object({ accepted: z.boolean() }) }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Recorded", + }, + 404: { description: "Not one of your runs" }, + }, + }, + handler: async c => { + const userId = c.get("admin")?.user.id; + if (!userId) throw new HTTPException(403); + const { id } = c.req.valid("param"); + const { accepted } = c.req.valid("json"); + + const updated = await c + .get("db") + .update(core_ai_runs) + .set({ accepted, acceptedAt: new Date() }) + .where( + and( + eq(core_ai_runs.id, id), + eq(core_ai_runs.userId, userId), + eq(core_ai_runs.status, "succeeded"), + ), + ) + .returning({ id: core_ai_runs.id }); + if (updated.length === 0) { + throw new HTTPException(404, { message: "AI run not found" }); + } + + return c.json({ ok: true as const }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/ai/ai.module.ts b/packages/vitnode/src/api/modules/ai/ai.module.ts index 68549ddaf..48aa8f769 100644 --- a/packages/vitnode/src/api/modules/ai/ai.module.ts +++ b/packages/vitnode/src/api/modules/ai/ai.module.ts @@ -3,16 +3,26 @@ import { CONFIG_PLUGIN } from "@/config"; import { aiQueueCron, altDetectCron } from "./cron/alt.cron"; import { aiMaintenanceCron } from "./cron/maintenance.cron"; +import { availableAiActionsRoute } from "./routes/available.route"; import { aiFeedbackRoute } from "./routes/feedback.route"; import { aiHistoryRoute } from "./routes/history.route"; import { runAiRoute } from "./routes/run.route"; +import { estimateAiRoute, streamAiRoute } from "./routes/stream.route"; import { aiUsageRoute } from "./routes/usage.route"; import { altGenerateQueueTask } from "./tasks/alt-generate.task"; export const aiModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "ai", - routes: [aiUsageRoute, aiHistoryRoute, aiFeedbackRoute, runAiRoute], + routes: [ + aiUsageRoute, + aiHistoryRoute, + aiFeedbackRoute, + runAiRoute, + availableAiActionsRoute, + streamAiRoute, + estimateAiRoute, + ], cronJobs: [aiMaintenanceCron, altDetectCron, aiQueueCron], queueTasks: [altGenerateQueueTask], }); diff --git a/packages/vitnode/src/api/modules/ai/routes/available.route.ts b/packages/vitnode/src/api/modules/ai/routes/available.route.ts new file mode 100644 index 000000000..18e4a7546 --- /dev/null +++ b/packages/vitnode/src/api/modules/ai/routes/available.route.ts @@ -0,0 +1,33 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { availableAiActions } from "@/api/lib/ai/available-actions"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +/** Which AI actions the signed-in user may start - for showing AI buttons. */ +export const availableAiActionsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: "The AI actions you may start.", + path: "/available", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ actions: z.array(z.string()) }), + }, + }, + description: "Available action keys", + }, + }, + }, + handler: async c => { + const user = c.get("user"); + if (!user) return c.json({ actions: [] }, 200); + if (!user.id) throw new HTTPException(401); + + return c.json({ actions: await availableAiActions(c, user.id) }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts b/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts new file mode 100644 index 000000000..0e8088cc5 --- /dev/null +++ b/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { convertArrayToReadableStream, MockLanguageModelV4 } from "ai/test"; +import { describe, expect, it } from "vitest"; + +import { + editorQuickAskAiAction, + editorRewriteAiAction, +} from "@/api/lib/ai/editor-actions"; +import { MemoryAiLedger } from "@/api/lib/ai/memory-ledger"; +import { collectAiActions } from "@/api/lib/ai/registry"; +import { AIModel } from "@/api/models/ai"; + +import { estimateAiRoute, streamAiRoute } from "./stream.route"; + +const USER_ID = 5; + +const streamingModel = (chunks: string[]) => + new MockLanguageModelV4({ + doStream: async () => + Promise.resolve({ + stream: convertArrayToReadableStream([ + { id: "t", type: "text-start" as const }, + ...chunks.map(delta => ({ + delta, + id: "t", + type: "text-delta" as const, + })), + { id: "t", type: "text-end" as const }, + { + finishReason: { raw: "stop", unified: "stop" as const }, + type: "finish" as const, + usage: { + inputTokens: { + cacheRead: 0, + cacheWrite: 0, + noCache: 10, + total: 10, + }, + outputTokens: { reasoning: 0, text: 5, total: 5 }, + }, + }, + ]), + }), + }); + +const createApp = ( + model: MockLanguageModelV4, + ledger = new MemoryAiLedger(), +) => { + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("user", { id: USER_ID } as never); + c.set("admin", null); + c.set( + "core" as never, + { + ai: { + models: [ + { + id: "default", + model, + name: "Test", + pricing: { + rates: { inputPerMillion: "1000", outputPerMillion: "2000" }, + }, + }, + ], + }, + aiActions: collectAiActions([ + { + aiActions: [editorRewriteAiAction, editorQuickAskAiAction], + pluginId: "@vitnode/core", + }, + ]), + } as never, + ); + c.set("ai", new AIModel(c as unknown as Context, { ledger })); + await next(); + }); + app.openapi(streamAiRoute.route, streamAiRoute.handler); + app.openapi(estimateAiRoute.route, estimateAiRoute.handler); + + return { app, ledger }; +}; + +const rewrite = { + action: "@vitnode/core:editor.selection.rewrite", + input: { + after: "", + before: "", + locale: "en", + operation: "shorten", + selection: "This is a rather long sentence that could be shorter.", + }, +}; + +const post = async (app: OpenAPIHono, path: string, body: unknown) => + await app.request(path, { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + +describe("POST /stream", () => { + it("streams NDJSON deltas and ends with the run summary", async () => { + const { app, ledger } = createApp( + streamingModel(["A shorter", " sentence."]), + ); + + const response = await post(app, "/stream", rewrite); + const lines = (await response.text()) + .trim() + .split("\n") + .map(line => JSON.parse(line) as Record); + + expect(response.headers.get("content-type")).toContain( + "application/x-ndjson", + ); + expect(lines).toEqual([ + { t: "A shorter" }, + { t: " sentence." }, + { + done: { + chargedPoints: "20", + costKnown: true, + modelId: "default", + runId: 1, + }, + }, + ]); + expect(ledger.spentPoints(USER_ID)).toBe(20_000_000_000_000n); + }); + + it("cancels the provider call and charges nothing when the client goes away", async () => { + const { app, ledger } = createApp( + streamingModel(["One", " two", " three", " four"]), + ); + + const response = await post(app, "/stream", rewrite); + const reader = response.body?.getReader(); + await reader?.read(); + await reader?.cancel("closed the panel"); + + await expect.poll(() => ledger.runs[0]?.status).toBe("canceled"); + expect(ledger.spentPoints(USER_ID)).toBe(0n); + }); + + it("refuses before streaming when the editor permission is not granted", async () => { + const { app } = createApp( + streamingModel(["x"]), + new MemoryAiLedger({ + policy: () => ({ + dailyLimit: null, + granted: false, + monthlyPoints: null, + }), + }), + ); + + const response = await post(app, "/stream", rewrite); + + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ code: "AI_UNAUTHORIZED" }); + }); +}); + +describe("POST /estimate", () => { + it("answers an upper bound before anything runs", async () => { + const model = streamingModel(["x"]); + const { app, ledger } = createApp(model); + + const response = await post(app, "/estimate", rewrite); + const estimate = (await response.json()) as { maxPoints: string }; + + expect(Number(estimate.maxPoints)).toBeGreaterThan(20); + expect(ledger.runs).toHaveLength(0); + expect(model.doStreamCalls).toHaveLength(0); + }); +}); diff --git a/packages/vitnode/src/api/modules/ai/routes/stream.route.ts b/packages/vitnode/src/api/modules/ai/routes/stream.route.ts new file mode 100644 index 000000000..70a3aa034 --- /dev/null +++ b/packages/vitnode/src/api/modules/ai/routes/stream.route.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; + +import { zodAiRunBody } from "@/api/lib/ai/run-schemas"; +import { + AI_NDJSON_HEADERS, + aiNdjsonStream, +} from "@/api/lib/ai/stream-response"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +/** Streams a text action for the signed-in user (the editor's Quick Ask). */ +export const streamAiRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Stream a registered AI text action for the signed-in user.", + path: "/stream", + request: { + body: { + required: true, + content: { "application/json": { schema: zodAiRunBody } }, + }, + }, + responses: { + 200: { + content: { "application/x-ndjson": { schema: z.string() } }, + description: "Text deltas, then the run summary", + }, + }, + }, + handler: async c => { + const body = c.req.valid("json"); + + const stream = await aiNdjsonStream(c, { + ...body, + resource: body.resource ?? undefined, + }); + + // A streamed body has no typed JSON response for the OpenAPI handler type + // to check; limits were already enforced before the first byte. + return new Response(stream, { + headers: AI_NDJSON_HEADERS, + status: 200, + }) as never; + }, +}); + +export const estimateAiRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: + "The most a run could cost you, before running it. An upper bound, not a price.", + path: "/estimate", + request: { + body: { + required: true, + content: { "application/json": { schema: zodAiRunBody } }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + maxPoints: z.string().nullable(), + maxUsd: z.string().nullable(), + }), + }, + }, + description: "Upper bound", + }, + }, + }, + handler: async c => { + const body = c.req.valid("json"); + const estimate = await c.get("ai").estimate({ + ...body, + resource: body.resource ?? undefined, + }); + + return c.json(estimate, 200); + }, +}); diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index f9acc86db..baa051b76 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -8,6 +8,10 @@ import { altGenerateAiAction, altTranslateAiAction, } from "./lib/ai/alt-actions"; +import { + editorQuickAskAiAction, + editorRewriteAiAction, +} from "./lib/ai/editor-actions"; import { buildApiPlugin } from "./lib/plugin"; import { adminModule } from "./modules/admin/admin.module"; import { aiModule } from "./modules/ai/ai.module"; @@ -22,7 +26,12 @@ import { usersModule } from "./modules/users/users.module"; export const newBuildPluginApiCore = buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, - aiActions: [altGenerateAiAction, altTranslateAiAction], + aiActions: [ + altGenerateAiAction, + altTranslateAiAction, + editorRewriteAiAction, + editorQuickAskAiAction, + ], blocks, editablePages: [settingsPage], navigation: [ diff --git a/packages/vitnode/src/components/ai/ai-field-assist.test.tsx b/packages/vitnode/src/components/ai/ai-field-assist.test.tsx new file mode 100644 index 000000000..32ba61bec --- /dev/null +++ b/packages/vitnode/src/components/ai/ai-field-assist.test.tsx @@ -0,0 +1,81 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { describe, expect, it, vi } from "vitest"; + +import { AiSuggestionBody } from "./ai-field-assist"; + +const renderBody = (props: Partial[0]>) => { + const handlers = { + onAccept: vi.fn(), + onDiscard: vi.fn(), + onGenerate: vi.fn(), + }; + render( + + + , + ); + + return handlers; +}; + +describe("AiSuggestionBody", () => { + it("asks for a suggestion first, and only when the sources are filled", () => { + const { onGenerate } = renderBody({}); + fireEvent.click( + screen.getByRole("button", { name: "core.ai_assist.generate" }), + ); + expect(onGenerate).toHaveBeenCalledOnce(); + }); + + it("disables generating while a source field is empty", () => { + renderBody({ missingSources: ["title"] }); + + expect( + screen.getByRole("button", { + name: "core.ai_assist.generate", + }).disabled, + ).toBe(true); + expect(screen.getByText("core.ai_assist.missing_sources")).toBeTruthy(); + }); + + it("shows the suggestion for review and lets the editor accept or discard it", () => { + const { onAccept, onDiscard } = renderBody({ + suggestion: "A short teaser.", + }); + + expect(screen.getByText("A short teaser.")).toBeTruthy(); + fireEvent.click( + screen.getByRole("button", { name: "core.ai_assist.accept" }), + ); + fireEvent.click( + screen.getByRole("button", { name: "core.ai_assist.discard" }), + ); + expect(onAccept).toHaveBeenCalledOnce(); + expect(onDiscard).toHaveBeenCalledOnce(); + }); + + it("warns about stale sources and asks before replacing a newer edit", () => { + renderBody({ staleSource: true, suggestion: "Teaser", targetEdited: true }); + + expect(screen.getByText("core.ai_assist.stale_source")).toBeTruthy(); + expect(screen.getByText("core.ai_assist.target_edited")).toBeTruthy(); + expect( + screen.getByRole("button", { name: "core.ai_assist.replace_anyway" }), + ).toBeTruthy(); + }); + + it("announces generation progress", () => { + renderBody({ pending: true }); + + expect(screen.getByText("core.ai_assist.generating")).toBeTruthy(); + }); +}); diff --git a/packages/vitnode/src/components/ai/ai-field-assist.tsx b/packages/vitnode/src/components/ai/ai-field-assist.tsx new file mode 100644 index 000000000..c9c572881 --- /dev/null +++ b/packages/vitnode/src/components/ai/ai-field-assist.tsx @@ -0,0 +1,296 @@ +import { useQuery } from "@tanstack/react-query"; +import { SparklesIcon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useLocale, useTranslations } from "use-intl"; + +import type { ContentFieldAiAssist } from "@/content/types"; + +import { useMultiLangSelected } from "@/components/form/fields/multi-lang-language"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Spinner } from "@/components/ui/spinner"; +import { CONFIG_PLUGIN } from "@/config"; +import { + aiErrorCodeOf, + requestAiAssist, + sendAiFeedback, +} from "@/lib/ai/assist-client"; +import { + type AiFieldSuggestion, + applyAiSuggestion, + buildAiFieldInput, + fieldTextOf, + suggestionFreshness, +} from "@/lib/ai/suggestion"; +import { fetcher } from "@/tanstack/fetcher"; +import { + useContentFormValues, + useSetContentFormValue, +} from "@/views/admin/views/content/form/values"; + +/** The AI actions this admin may start; cached for the session. */ +export const useAvailableAiActions = () => + useQuery({ + queryFn: async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/ai", + path: "/assist/available", + }); + if (!response.ok) return [] as string[]; + + return (await response.json()).actions; + }, + queryKey: ["ai", "assist", "available"], + staleTime: 5 * 60_000, + }); + +/** + * The AI button of a text field with `ai` assistance. It asks the field's + * action for a suggestion from the source fields, in the language being + * edited, and shows it for review: nothing reaches the field until the + * editor accepts it, and saving goes through the normal form, validation and + * revisions. Shown only when the server says this admin may use the action. + */ +export const AiFieldAssist = ({ + ai, + fieldName, + multiLang, +}: { + ai: ContentFieldAiAssist; + fieldName: string; + multiLang: boolean; +}) => { + const t = useTranslations("core.ai_assist"); + const locale = useLocale(); + const selected = useMultiLangSelected(); + const language = selected ?? locale; + const values = useContentFormValues(); + const setValue = useSetContentFormValue(); + const { data: available = [] } = useAvailableAiActions(); + const [open, setOpen] = React.useState(false); + const [pending, setPending] = React.useState(false); + const [suggestion, setSuggestion] = React.useState( + null, + ); + const controllerRef = React.useRef(null); + + if (!available.includes(ai.action)) return null; + + const currentTarget = fieldTextOf(values[fieldName], language); + const { input, sourceFingerprint } = buildAiFieldInput({ + language, + sourceFields: ai.sourceFields, + values, + }); + const freshness = suggestion + ? suggestionFreshness({ + currentSourceFingerprint: sourceFingerprint, + currentTarget, + suggestion, + }) + : null; + const missingSources = ai.sourceFields.filter( + name => input[name].trim() === "", + ); + + const generate = async () => { + controllerRef.current?.abort(); + const abort = new AbortController(); + controllerRef.current = abort; + setPending(true); + const snapshot = currentTarget; + try { + const result = await requestAiAssist({ + action: ai.action, + input, + signal: abort.signal, + sourceFingerprint, + }); + if (typeof result.output !== "string") + throw new Error("AI_INVALID_OUTPUT"); + setSuggestion({ + language, + runId: result.runId, + sourceFingerprint, + targetSnapshot: snapshot, + text: result.output, + }); + } catch (error) { + if (abort.signal.aborted) return; + toast.error(t("error.title"), { + description: t(`error.${aiErrorCodeOf(error)}`), + }); + } finally { + if (controllerRef.current === abort) setPending(false); + } + }; + + const close = (accepted: boolean | null) => { + controllerRef.current?.abort(); + if (suggestion && accepted !== null) { + void sendAiFeedback({ accepted, runId: suggestion.runId }); + } + setSuggestion(null); + setOpen(false); + }; + + const accept = () => { + if (!suggestion) return; + setValue( + fieldName, + applyAiSuggestion({ + language: suggestion.language, + multiLang, + text: suggestion.text, + value: values[fieldName], + }), + ); + toast.success(t("accepted.title"), { + description: t("accepted.desc"), + }); + close(true); + }; + + return ( + { + if (!next) close(suggestion ? false : null); + else setOpen(true); + }} + open={open} + > + + } + > + + {t("button")} + + + + {t("title")} + + {t("desc", { language: language.toUpperCase() })} + + + + { + close(false); + }} + onGenerate={() => void generate()} + pending={pending} + staleSource={freshness?.staleSource ?? false} + suggestion={suggestion?.text ?? null} + targetEdited={freshness?.targetEdited ?? false} + /> + + + ); +}; + +/** The review panel: suggestion, freshness warnings and the three choices. */ +export const AiSuggestionBody = ({ + missingSources, + onAccept, + onDiscard, + onGenerate, + pending, + staleSource, + suggestion, + targetEdited, +}: { + missingSources: string[]; + onAccept: () => void; + onDiscard: () => void; + onGenerate: () => void; + pending: boolean; + staleSource: boolean; + suggestion: null | string; + targetEdited: boolean; +}) => { + const t = useTranslations("core.ai_assist"); + + return ( + <> + {missingSources.length > 0 ? ( +

+ {t("missing_sources", { fields: missingSources.join(", ") })} +

+ ) : null} + +
+ {pending ? ( +

+ {t("generating")} +

+ ) : null} + {suggestion !== null && !pending ? ( + <> +

+ {suggestion} +

+ {staleSource ? ( +

+ {t("stale_source")} +

+ ) : null} + {targetEdited ? ( +

+ {t("target_edited")} +

+ ) : null} + + ) : null} +
+ +
+ {suggestion !== null && !pending ? ( + <> + + + + + ) : ( + + )} +
+ + ); +}; diff --git a/packages/vitnode/src/components/form/fields/input.tsx b/packages/vitnode/src/components/form/fields/input.tsx index cb43d143f..05e0fb313 100644 --- a/packages/vitnode/src/components/form/fields/input.tsx +++ b/packages/vitnode/src/components/form/fields/input.tsx @@ -16,6 +16,7 @@ import { MultiLangSelect, useMultiLangField, } from "./multi-lang"; +import { MultiLangSelectedContext } from "./multi-lang-language"; type AutoFormInputProps = ItemAutoFormComponentProps & Omit, "value"> & { @@ -52,7 +53,16 @@ const MultiLangInput = ({ return ( <> {!!label && ( - + + {labelRight} + + ) : undefined + } + > {label} )} diff --git a/packages/vitnode/src/components/form/fields/multi-lang-language.ts b/packages/vitnode/src/components/form/fields/multi-lang-language.ts index 0e4a20332..09e65e1a1 100644 --- a/packages/vitnode/src/components/form/fields/multi-lang-language.ts +++ b/packages/vitnode/src/components/form/fields/multi-lang-language.ts @@ -6,3 +6,15 @@ export const MultiLangLanguageContext = React.createContext( export const useMultiLangLanguage = (): null | string => React.use(MultiLangLanguageContext); + +/** + * The language a multi-language field is currently showing, offered to what + * renders in its label slot - an AI button, for example, that must write into + * the language the editor is looking at. Read-only: it never locks anything. + */ +export const MultiLangSelectedContext = React.createContext( + null, +); + +export const useMultiLangSelected = (): null | string => + React.use(MultiLangSelectedContext); diff --git a/packages/vitnode/src/components/form/fields/textarea.tsx b/packages/vitnode/src/components/form/fields/textarea.tsx index 4c270d674..f48fb62ed 100644 --- a/packages/vitnode/src/components/form/fields/textarea.tsx +++ b/packages/vitnode/src/components/form/fields/textarea.tsx @@ -14,6 +14,7 @@ import { MultiLangSelect, useMultiLangField, } from "./multi-lang"; +import { MultiLangSelectedContext } from "./multi-lang-language"; type AutoFormTextareaProps = ItemAutoFormComponentProps & Omit, "value"> & { @@ -51,7 +52,16 @@ const MultiLangTextarea = ({ <>
{!!label && ( - + + {labelRight} + + ) : undefined + } + > {label} )} diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index 69df3ebf6..2e0afc7c4 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { AnyContentTypeDefinition, + ContentFieldAiAssist, ContentFieldDescriptor, ContentFieldKind, } from "../types"; @@ -27,6 +28,8 @@ import { import { humanizeFieldName } from "./labels"; export interface ContentFormFieldSpec { + /** `text`/`textarea` fields only: the AI assistance the field offers. */ + ai?: ContentFieldAiAssist; allowedExtensions?: string[]; /** `file` fields only: the media types the field accepts, lowercased. */ allowedMimeTypes?: string[]; @@ -237,6 +240,15 @@ export const projectFormField = ( case "textarea": return { ...base, + ...(fieldValue.ai + ? { + ai: { + action: fieldValue.ai.action, + mode: fieldValue.ai.mode, + sourceFields: [...fieldValue.ai.sourceFields], + }, + } + : {}), defaultValue: fieldValue.defaultValue, maxLength: fieldValue.maxLength, minLength: fieldValue.minLength, diff --git a/packages/vitnode/src/content/define-fields.ts b/packages/vitnode/src/content/define-fields.ts index f4901f3af..28f7aa626 100644 --- a/packages/vitnode/src/content/define-fields.ts +++ b/packages/vitnode/src/content/define-fields.ts @@ -334,3 +334,48 @@ export const bindSelfRelations = ( return bound; }; + +const AI_ACTION_KEY_PATTERN = /^[^:\s]+:[a-z0-9][a-z0-9.-]*$/; + +/** + * Checks every field's `ai` assistance against the content type: a canonical + * action key, existing source fields, and never the field itself. Whether the + * action is registered is checked at API boot, once every plugin is known. + */ +export const assertFieldAiAssist = ( + id: string, + fieldMap: ContentFieldMap, +): void => { + for (const [name, fieldValue] of Object.entries(fieldMap)) { + if (fieldValue.kind !== "text" && fieldValue.kind !== "textarea") continue; + const { ai } = fieldValue; + if (!ai) continue; + + if (!AI_ACTION_KEY_PATTERN.test(ai.action)) { + throw new ContentEngineError( + `Field "${name}" names AI action "${ai.action}". Use the canonical ":" key.`, + { contentTypeId: id }, + ); + } + if (ai.mode !== "suggestion") { + throw new ContentEngineError( + `Field "${name}" has an unsupported AI mode "${String(ai.mode)}".`, + { contentTypeId: id }, + ); + } + for (const source of ai.sourceFields) { + if (source === name) { + throw new ContentEngineError( + `Field "${name}" cannot use itself as an AI source field.`, + { contentTypeId: id }, + ); + } + if (!Object.hasOwn(fieldMap, source)) { + throw new ContentEngineError( + `Field "${name}" reads AI source field "${source}", which this content type does not have.`, + { contentTypeId: id }, + ); + } + } + } +}; diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 872079fb9..2c4298fbc 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -46,6 +46,7 @@ import { resolveAdmin } from "./define-admin"; import { resolveEditorial } from "./define-editorial"; import { assertField, + assertFieldAiAssist, assertFieldKind, assertFieldName, assertSlugSources, @@ -302,6 +303,7 @@ export const defineContentType = < } assertSlugSources(id, fieldMap); + assertFieldAiAssist(id, fieldMap); // First, because every resolver below is stated in terms of what it produces: // the generated table names, and above all the one leaf-path -> column mapping diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts index 9b02de90f..b87fc6f48 100644 --- a/packages/vitnode/src/content/fields.ts +++ b/packages/vitnode/src/content/fields.ts @@ -5,6 +5,7 @@ import type { ContentBooleanField, ContentDateTimeField, ContentEnumField, + ContentFieldAiAssist, ContentFileField, ContentGroupField, ContentNumberField, @@ -57,6 +58,7 @@ const text = < >( args: LocalizableArgs & SharedArgs & { + ai?: ContentFieldAiAssist; defaultValue?: TDefault; maxLength?: number; minLength?: number; @@ -78,6 +80,7 @@ const textarea = < >( args: LocalizableArgs & SharedArgs & { + ai?: ContentFieldAiAssist; defaultValue?: TDefault; maxLength?: number; minLength?: number; diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index d8340576d..f3033a650 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -48,6 +48,27 @@ export type Prettify = { [K in keyof T]: T[K] } & {}; // derived from them. // --------------------------------------------------------------------------- +/** + * AI assistance on a text field: a registered AI action that writes a + * suggestion for it from other fields. Plain JSON - the definition also + * reaches the browser. Storage is the field's own; nothing AI-specific is + * stored with the content. + */ +export interface ContentFieldAiAssist { + /** Canonical AI action key, `:`. */ + action: `${string}:${string}`; + /** + * `suggestion`: the editor previews the result and accepts or discards it. + * Nothing is ever written to the field without that acceptance. + */ + mode: "suggestion"; + /** + * Fields the action reads, in the locale being edited. The action's input + * is `{ [field]: value, locale }` - one key per source field. + */ + sourceFields: readonly string[]; +} + export interface ContentFieldShared< TRequired extends boolean = boolean, TNullable extends boolean = boolean, @@ -68,6 +89,7 @@ export interface ContentTextField< TDefault extends string | undefined = string | undefined, TLocalized extends boolean = boolean, > extends ContentFieldShared { + ai?: ContentFieldAiAssist; // Declared non-optional (but possibly `undefined`) so `HasColumnDefault` can // tell "no default" from "defaulted": an optional property would always // include `undefined` in its type and the distinction would be lost. @@ -104,6 +126,7 @@ export interface ContentTextareaField< TDefault extends string | undefined = string | undefined, TLocalized extends boolean = boolean, > extends ContentFieldShared { + ai?: ContentFieldAiAssist; defaultValue: TDefault; kind: "textarea"; localized: TLocalized; diff --git a/packages/vitnode/src/lib/ai/assist-client.ts b/packages/vitnode/src/lib/ai/assist-client.ts new file mode 100644 index 000000000..4df79c884 --- /dev/null +++ b/packages/vitnode/src/lib/ai/assist-client.ts @@ -0,0 +1,113 @@ +import { CONFIG_PLUGIN } from "@/config"; +import { fetcherClient } from "@/lib/fetcher-client"; + +/** Error codes the browser has messages for; anything else is `unknown`. */ +export const AI_CLIENT_ERROR_CODES = [ + "AI_ACTION_DISABLED", + "AI_BUDGET_EXHAUSTED", + "AI_CANCELED", + "AI_CONCURRENCY_LIMITED", + "AI_DAILY_LIMIT_REACHED", + "AI_DISABLED", + "AI_INPUT_TOO_LARGE", + "AI_INVALID_INPUT", + "AI_INVALID_OUTPUT", + "AI_MODEL_INCOMPATIBLE", + "AI_NOT_CONFIGURED", + "AI_PRICING_MISSING", + "AI_PROVIDER_FAILED", + "AI_RATE_LIMITED", + "AI_TIMEOUT", + "AI_UNAUTHORIZED", + "AI_USER_LIMIT_REACHED", +] as const; + +export type AiClientErrorCode = + "unknown" | (typeof AI_CLIENT_ERROR_CODES)[number]; + +export class AiRequestError extends Error { + constructor(code: AiClientErrorCode, status: number) { + super(code); + this.name = "AiRequestError"; + this.code = code; + this.status = status; + } + + readonly code: AiClientErrorCode; + readonly status: number; +} + +export const aiErrorCodeOf = (error: unknown): AiClientErrorCode => + error instanceof AiRequestError ? error.code : "unknown"; + +export const readAiErrorCode = async ( + response: Response, +): Promise => { + try { + const body = (await response.json()) as { code?: unknown }; + const code = typeof body.code === "string" ? body.code : ""; + + return (AI_CLIENT_ERROR_CODES as readonly string[]).includes(code) + ? (code as AiClientErrorCode) + : "unknown"; + } catch { + return "unknown"; + } +}; + +/** Runs an action for the signed-in admin; resolves with its validated output. */ +export const requestAiAssist = async ({ + action, + input, + signal, + sourceFingerprint, +}: { + action: string; + input: unknown; + signal?: AbortSignal; + sourceFingerprint?: string; +}): Promise<{ output: unknown; runId: number }> => { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { + body: { + action, + idempotencyKey: crypto.randomUUID(), + input, + sourceFingerprint, + }, + }, + method: "post", + module: "admin/ai", + options: { credentials: "include", signal }, + path: "/assist", + }); + if (!response.ok) { + throw new AiRequestError(await readAiErrorCode(response), response.status); + } + const { output, runId } = await response.json(); + + return { output, runId }; +}; + +/** Records acceptance for analytics. Never affects billing; failures are ignored. */ +export const sendAiFeedback = async ({ + accepted, + runId, +}: { + accepted: boolean; + runId: number; +}): Promise => { + try { + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: { accepted }, params: { id: runId } }, + method: "post", + module: "admin/ai", + options: { credentials: "include" }, + path: "/assist/runs/{id}/feedback", + }); + } catch { + /* tracking only */ + } +}; diff --git a/packages/vitnode/src/lib/ai/run-status.ts b/packages/vitnode/src/lib/ai/run-status.ts new file mode 100644 index 000000000..9f6fbd245 --- /dev/null +++ b/packages/vitnode/src/lib/ai/run-status.ts @@ -0,0 +1,35 @@ +/** + * The states an AI run moves through - `AI_RUN_STATUSES` in `database/ai`, + * repeated here so a browser bundle never imports a Drizzle table to read it. + */ +export const AI_RUN_STATUS_LIST = [ + "reserved", + "running", + "succeeded", + "failed", + "canceled", + "uncertain", +] as const; + +export type AiRunStatusValue = (typeof AI_RUN_STATUS_LIST)[number]; + +export const isAiRunStatus = (value: string): value is AiRunStatusValue => + (AI_RUN_STATUS_LIST as readonly string[]).includes(value); + +/** The badge tone a status reads as. */ +export const aiRunStatusVariant = ( + status: string, +): "destructive" | "outline" | "secondary" | "success" | "warning" => { + switch (status) { + case "canceled": + return "outline"; + case "failed": + return "destructive"; + case "succeeded": + return "success"; + case "uncertain": + return "warning"; + default: + return "secondary"; + } +}; diff --git a/packages/vitnode/src/lib/ai/suggestion.test.ts b/packages/vitnode/src/lib/ai/suggestion.test.ts new file mode 100644 index 000000000..03dc8acac --- /dev/null +++ b/packages/vitnode/src/lib/ai/suggestion.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { + applyAiSuggestion, + buildAiFieldInput, + suggestionFreshness, +} from "./suggestion"; + +const values = { + content: [ + { languageCode: "en", value: "

Body

" }, + { languageCode: "pl", value: "

Treść

" }, + ], + title: [ + { languageCode: "en", value: "Hello" }, + { languageCode: "pl", value: "Cześć" }, + ], +}; + +describe("buildAiFieldInput", () => { + it("reads every source field in the edited language", () => { + const { input } = buildAiFieldInput({ + language: "pl", + sourceFields: ["title", "content"], + values, + }); + + expect(input).toEqual({ + content: "

Treść

", + locale: "pl", + title: "Cześć", + }); + }); + + it("changes its fingerprint when a source changes", () => { + const before = buildAiFieldInput({ + language: "en", + sourceFields: ["title"], + values, + }); + const after = buildAiFieldInput({ + language: "en", + sourceFields: ["title"], + values: { ...values, title: [{ languageCode: "en", value: "Hi" }] }, + }); + + expect(before.sourceFingerprint).not.toBe(after.sourceFingerprint); + }); +}); + +describe("suggestion freshness", () => { + const suggestion = { + language: "en", + runId: 1, + sourceFingerprint: "abc", + targetSnapshot: "old excerpt", + text: "New excerpt", + }; + + it("flags changed sources and a newer edit of the field", () => { + expect( + suggestionFreshness({ + currentSourceFingerprint: "abc", + currentTarget: "old excerpt", + suggestion, + }), + ).toEqual({ staleSource: false, targetEdited: false }); + expect( + suggestionFreshness({ + currentSourceFingerprint: "xyz", + currentTarget: "typed meanwhile", + suggestion, + }), + ).toEqual({ staleSource: true, targetEdited: true }); + }); +}); + +describe("applyAiSuggestion", () => { + it("replaces only the edited language", () => { + expect( + applyAiSuggestion({ + language: "pl", + multiLang: true, + text: "Nowy", + value: values.title, + }), + ).toEqual([ + { languageCode: "en", value: "Hello" }, + { languageCode: "pl", value: "Nowy" }, + ]); + expect( + applyAiSuggestion({ + language: "en", + multiLang: false, + text: "x", + value: "y", + }), + ).toBe("x"); + }); +}); diff --git a/packages/vitnode/src/lib/ai/suggestion.ts b/packages/vitnode/src/lib/ai/suggestion.ts new file mode 100644 index 000000000..556f0b139 --- /dev/null +++ b/packages/vitnode/src/lib/ai/suggestion.ts @@ -0,0 +1,87 @@ +import { fingerprint } from "@/content/hash"; +import { getLangValue, upsertLangValue } from "@/lib/helpers/multi-lang"; + +type FieldValue = + string | undefined | { languageCode: string; value: string }[]; + +const textOf = (value: unknown, language: string): string => + typeof value === "string" || Array.isArray(value) + ? getLangValue(value as FieldValue, language) + : typeof value === "number" || typeof value === "boolean" + ? String(value) + : ""; + +/** + * The input an AI field action receives: one key per source field, read in + * the language being edited, plus `locale`. The fingerprint identifies those + * exact source values, so a suggestion made from older text can be flagged. + */ +export const buildAiFieldInput = ({ + language, + sourceFields, + values, +}: { + language: string; + sourceFields: readonly string[]; + values: Record; +}): { input: Record; sourceFingerprint: string } => { + const sources = Object.fromEntries( + sourceFields.map(name => [name, textOf(values[name], language)]), + ); + + return { + input: { ...sources, locale: language }, + sourceFingerprint: fingerprint(JSON.stringify(sources)), + }; +}; + +export interface AiFieldSuggestion { + language: string; + runId: number; + sourceFingerprint: string; + /** The field's value when the suggestion was requested. */ + targetSnapshot: string; + text: string; +} + +/** + * Whether a suggestion still fits the form: the sources it was made from may + * have changed, and the editor may have typed into the field meanwhile - + * in which case accepting must not silently replace their newer words. + */ +export const suggestionFreshness = ({ + currentSourceFingerprint, + currentTarget, + suggestion, +}: { + currentSourceFingerprint: string; + currentTarget: string; + suggestion: AiFieldSuggestion; +}): { staleSource: boolean; targetEdited: boolean } => ({ + staleSource: currentSourceFingerprint !== suggestion.sourceFingerprint, + targetEdited: currentTarget !== suggestion.targetSnapshot, +}); + +/** The field value after accepting - only the edited language changes. */ +export const applyAiSuggestion = ({ + language, + multiLang, + text, + value, +}: { + language: string; + multiLang: boolean; + text: string; + value: unknown; +}): unknown => + multiLang + ? upsertLangValue( + Array.isArray(value) + ? (value as { languageCode: string; value: string }[]) + : undefined, + language, + text, + ) + : text; + +export const fieldTextOf = textOf; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 48aa8bf4f..37a2f6647 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -834,7 +834,8 @@ "devices": "Devices", "security": "Security", "sso": "Connected accounts", - "notifications": "Notifications" + "notifications": "Notifications", + "ai": "AI usage" }, "overview": { "desc": "Your avatar and the details on your account.", @@ -1169,6 +1170,62 @@ "daily": "Daily digest", "weekly": "Weekly digest" } + }, + "ai": { + "title": "AI usage", + "desc": "Your monthly AI points, what each feature allows per day and what you've used recently.", + "notice": { + "disabled": { + "title": "AI features are switched off", + "desc": "This site isn't offering AI features right now. Your points stay put until they're back." + }, + "site_paused": { + "title": "AI features are paused for everyone", + "desc": "The site reached its monthly AI budget. This isn't about your allowance - features come back when the budget resets or an administrator raises it." + }, + "exhausted": { + "title": "You've used all your AI points", + "desc": "Your allowance refills on {resetsAt}. Until then AI features won't run for your account." + }, + "near_limit": { + "title": "You've used over 80% of your AI points", + "desc": "Spend the rest wisely - your allowance refills on {resetsAt}." + } + }, + "points": { + "title": "Monthly allowance", + "footer": "Points are reserved while an AI feature runs and only the actual cost is charged. Unused points don't roll over.", + "used": "Points used", + "used_of": "{used} of {total} points", + "used_unlimited": "{used} points (unlimited)", + "available": "Available", + "reserved": "Reserved", + "resets": "Resets on", + "unlimited": "Unlimited" + }, + "daily": { + "title": "Daily limits per feature", + "footer": "Some features also limit how many times a day you can use them, whatever your points.", + "used_of": "{used} of {limit} today", + "no_limit": "{used} today, no daily limit", + "empty": "No AI features are available to your account." + }, + "history": { + "title": "Recent operations", + "footer": "Only your own operations are listed. Prompts and generated text are never stored.", + "empty": "You haven't used any AI features yet.", + "points": "{points} pts", + "no_charge": "Not charged", + "load_more": "Load more" + }, + "status": { + "reserved": "Queued", + "running": "Running", + "succeeded": "Done", + "failed": "Failed", + "canceled": "Canceled", + "uncertain": "Checking" + } } } }, @@ -1620,6 +1677,46 @@ "empty_title": "Nothing here yet", "empty_unread_title": "No unread notifications", "empty_desc": "When something needs your attention, it lands here." + }, + "ai_assist": { + "button": "AI", + "open": "AI suggestion for {field}", + "title": "AI suggestion", + "desc": "Written from the other fields in {language}. Review it before you use it.", + "missing_sources": "Fill in {fields} first.", + "generating": "Writing a suggestion…", + "generate": "Generate", + "regenerate": "Try again", + "discard": "Discard", + "accept": "Use suggestion", + "replace_anyway": "Replace my newer text", + "stale_source": "The text it was written from changed since. Try again for an up-to-date suggestion.", + "target_edited": "You edited this field after asking. Using the suggestion replaces your newer text.", + "accepted": { + "title": "Suggestion added", + "desc": "Save the form to keep it. You can undo by editing the field." + }, + "error": { + "title": "No AI suggestion", + "AI_ACTION_DISABLED": "An administrator switched this AI feature off.", + "AI_BUDGET_EXHAUSTED": "The site's AI budget is used up for now. It is not your limit.", + "AI_CANCELED": "The request was canceled.", + "AI_CONCURRENCY_LIMITED": "Another AI request of yours is still running. Try again in a moment.", + "AI_DAILY_LIMIT_REACHED": "You reached today's limit for this AI feature.", + "AI_DISABLED": "AI features are switched off on this site.", + "AI_INPUT_TOO_LARGE": "The text is too long for this AI feature.", + "AI_INVALID_INPUT": "Some of the source text is missing or invalid.", + "AI_INVALID_OUTPUT": "The AI answer was not usable. Nothing was charged to you.", + "AI_MODEL_INCOMPATIBLE": "No configured AI model can do this yet.", + "AI_NOT_CONFIGURED": "No AI model is configured.", + "AI_PRICING_MISSING": "The AI model has no pricing, so its cost cannot be limited.", + "AI_PROVIDER_FAILED": "The AI provider failed. Nothing was charged to you.", + "AI_RATE_LIMITED": "Too many AI requests. Try again in a minute.", + "AI_TIMEOUT": "The AI provider took too long. Nothing was charged to you.", + "AI_UNAUTHORIZED": "You do not have access to this AI feature or this content.", + "AI_USER_LIMIT_REACHED": "You used all your AI points for this month.", + "unknown": "Something went wrong. Try again." + } } }, "admin": { @@ -1744,6 +1841,15 @@ "cron": "Cron Jobs", "queue": "Queue Tasks", "search": "Search" + }, + "ai": { + "title": "AI", + "overview": "Overview", + "models": "Models & pricing", + "actions": "Actions", + "access": "Access & limits", + "history": "History", + "settings": "Settings" } }, "search": { @@ -2802,6 +2908,461 @@ "headerDesc": "In the header on tablets and desktops, and in the side menu on phones.", "bottomBarDesc": "Up to three links at the bottom of the screen on phones, plus a Menu tab with the rest of the header menu." } + }, + "ai": { + "cost": { + "unknown": "Unknown", + "source": { + "provider": "Provider-reported", + "estimated": "Estimated", + "mixed": "Partly estimated", + "unknown": "Cost unknown" + } + }, + "status": { + "reserved": "Reserved", + "running": "Running", + "succeeded": "Succeeded", + "failed": "Failed", + "canceled": "Canceled", + "uncertain": "Uncertain" + }, + "origin": { + "user": "Members", + "system": "Background jobs" + }, + "overview": { + "title": "AI overview", + "desc": "What AI costs this month, where it goes and how sure we are about the numbers.", + "period": { + "label": "Period", + "current": "This month", + "previous": "Last month" + }, + "period_range": "{start} – {end}", + "stats_label": "Key numbers", + "disabled": { + "title": "AI is switched off site-wide", + "desc": "No AI feature runs for anyone until it's switched back on.", + "link": "Open AI settings" + }, + "budget": { + "spent": "Charged to the budget", + "no_limit": "No monthly budget is set.", + "set_limit": "Set one", + "of_limit": "{spent} of {limit}", + "remaining": "{remaining} left", + "reserved": "{reserved} reserved for operations in progress" + }, + "operations": "Operations", + "failure_rate": "{share} failed", + "average_operation": "Average cost per operation", + "average_daily": "About {amount} a day", + "tokens": "Tokens", + "tokens_split": "{input} in · {output} out", + "system_budget": "Background jobs used {spent} of {limit}.", + "system_no_limit": "no separate limit", + "coverage": { + "title": "How sure are these numbers?", + "summary": "Cost known for {share} of operations.", + "provider": "Provider-reported", + "estimated": "Estimated", + "unknown": "Unknown", + "unknown_hint": "Operations with an unknown cost are charged at their reservation and left out of averages - never counted as $0. Add a price for the model to fix it.", + "all_known_hint": "Every operation has a cost: either reported by the provider or estimated from the model's price." + }, + "alt": { + "title": "Automatic ALT text", + "desc": "Image descriptions written in the background, with their translations. Averages use known costs only.", + "images": "Images described", + "translations": "Translations", + "per_image": "Per image", + "per_translation": "Per translation" + }, + "breakdown": { + "title": "Where it goes", + "by_action": "By action", + "by_model": "By model", + "by_origin": "By origin", + "name": "Name", + "operations": "Operations", + "failures": "Failures", + "known_cost": "Known cost", + "coverage": "Cost known", + "empty": "Nothing ran in this period yet." + } + }, + "settings": { + "title": "AI settings", + "desc": "The site-wide switch, budgets and limits every AI feature runs under.", + "time_zone": "Monthly budgets and allowances reset at midnight in {timeZone}.", + "tabs": { + "budget": "Budget", + "limits": "Limits", + "alt": "ALT text" + }, + "enabled": { + "label": "AI features", + "desc": "The master switch. Turned off, no AI feature runs for anyone - members or background jobs." + }, + "monthly_budget": { + "label": "Monthly site budget", + "desc": "The most the whole site may spend on AI in a month. When it runs out, every AI feature pauses until the next month." + }, + "system_budget": { + "label": "Background jobs budget", + "desc": "A separate cap for automation such as ALT text, inside the site budget." + }, + "no_limit": "No limit", + "or": "or", + "default_points": { + "label": "Default monthly points", + "desc": "What a member gets each month unless their role says otherwise. One point is worth {usd}." + }, + "points_unit": "points", + "requests_per_minute": { + "label": "Requests per minute", + "desc": "How often one member may start an AI feature." + }, + "user_concurrency": { + "label": "Parallel runs per member", + "desc": "How many AI features one member may have running at once." + }, + "system_concurrency": { + "label": "Parallel background runs", + "desc": "How many background AI jobs may run at once across the site." + }, + "retention": { + "label": "Keep history for", + "desc": "Older operation records are deleted. Prompts and outputs are never stored either way." + }, + "days_unit": "days", + "alt": { + "enabled": { + "label": "Write ALT text automatically", + "desc": "Describe uploaded images in the background. Needs a monthly site budget, so it can never spend without a cap." + }, + "needs_budget": "Set a monthly site budget before turning on automatic ALT text.", + "languages": { + "label": "Languages", + "desc": "Comma-separated language codes. Leave empty to write ALT text in every enabled language.", + "placeholder": "en, pl" + }, + "batch_size": { + "label": "Images per batch", + "desc": "How many images one background job describes." + } + }, + "submit": "Save settings", + "saved": { + "title": "AI settings saved", + "desc": "New limits apply to the next AI operation." + } + }, + "models": { + "title": "Models & pricing", + "desc": "The models this site can use, what they can do and what they cost.", + "capabilities": "Capabilities", + "capability": { + "text": "Text", + "image-input": "Image input", + "structured-output": "Structured output", + "streaming": "Streaming" + }, + "source": { + "config": "From config", + "sync": "From AI Gateway", + "manual": "Manual override", + "none": "No price" + }, + "per_million_hint": "USD per 1M tokens", + "no_price": "Without a price, costs from this model are unknown: they're charged at their reservation and never shown as $0.", + "catalog_hint": "A manual price overrides the catalog price. Reset it to use the catalog again.", + "updated": "Price updated", + "price": { + "input": "Input", + "output": "Output", + "cache_read": "Cache read", + "cache_write": "Cache write", + "per_request": "Per request", + "per_image": "Per image", + "tiers": "{count, plural, one {# long-context tier} other {# long-context tiers}} kept as configured" + }, + "pricing_form": { + "open": "Edit price", + "title": "Price for {name}", + "desc": "Earlier operations keep the price they were charged at. This one applies from now on.", + "input": "Input", + "input_desc": "Prices are per one million tokens, in USD.", + "output": "Output", + "cache_read": "Cache read", + "cache_write": "Cache write", + "per_request": "Flat fee per request", + "per_image": "Flat fee per image", + "per_million": "USD / 1M tokens", + "per_request_unit": "USD / request", + "per_image_unit": "USD / image", + "or": "or", + "not_billed": "Not billed", + "submit": "Save price", + "saved": { + "title": "Price saved", + "desc": "{name} now uses your price." + } + }, + "reset": { + "open": "Reset", + "title": "Remove the manual price?", + "desc": "{name} goes back to its catalog price. Operations already charged keep their price.", + "cancel": "Cancel", + "confirm": "Remove override", + "success": "Manual price removed", + "success_desc": "{name} uses its catalog price again." + }, + "sync": { + "button": "Sync gateway prices", + "success": "Prices synced", + "success_desc": "{updated} updated, {unchanged} unchanged, {unpriced} without a published price.", + "error": "Prices could not be synced", + "error_desc": "The AI Gateway price list could not be read. Try again in a moment." + }, + "empty": { + "title": "No AI models configured", + "desc": "Add a model to the AI section of your VitNode config to get started." + } + }, + "actions": { + "title": "AI actions", + "desc": "Every AI feature your plugins register: switch them, pick their model and set their limits.", + "enabled": "On", + "disabled": "Off", + "model": "Model", + "model_default": "Site default", + "daily_limit": "Daily limit", + "no_limit": "None", + "actors": "Used by", + "compatible": "Compatible models", + "no_compatible": "None - it can't run", + "empty": { + "title": "No AI actions yet", + "desc": "Plugins register AI actions; once one does, it shows up here." + }, + "form": { + "open": "Configure", + "title": "Configure action", + "tabs": { + "general": "General", + "limits": "Limits" + }, + "enabled": "Enabled", + "enabled_desc": "A disabled action refuses to run for anyone.", + "model": "Model", + "model_desc": "Only models with every capability this action needs are listed.", + "model_default": "Site default", + "fallback": "Fallback model", + "fallback_desc": "Tried when the main model fails.", + "instructions": "Editorial instructions", + "instructions_desc": "Extra guidance added to the prompt, e.g. tone or house style. Up to 2,000 characters.", + "daily_limit": "Runs per member per day", + "max_input_characters": "Max input characters", + "max_output_tokens": "Max output tokens", + "max_retries": "Max retries", + "max_steps": "Max steps", + "timeout": "Timeout", + "or": "or", + "use_default": "Use default ({value})", + "use_default_none": "Use default (no limit)", + "submit": "Save action", + "saved": { + "title": "Action saved", + "desc": "{key} uses the new settings from its next run." + } + }, + "test": { + "open": "Test", + "title": "Test run", + "billed": { + "title": "This is a real, billed run", + "desc": "It goes through the same limits and accounting as any other operation, charges your own points and shows up in history." + }, + "input": "Input (JSON)", + "input_desc": "The action's input, exactly as a plugin would send it.", + "invalid_json": "This isn't valid JSON.", + "submit": "Run test", + "result": "Result", + "charged": "{points} points charged", + "cost_unknown": "Cost unknown", + "run_id": "Run #{id} in history.", + "failed": "The test run failed", + "done": { + "title": "Test run finished", + "desc": "Saved as run #{id} in the AI history." + } + } + }, + "access": { + "title": "Access & limits", + "desc": "Who may use which AI feature and how much of it.", + "unlimited": "Unlimited", + "site_default": "Site default", + "points_month": "{points} points / month", + "layers": { + "title": "Four separate controls", + "permission": { + "title": "Permission", + "desc": "Whether a role may use a feature at all." + }, + "points": { + "title": "Monthly points", + "desc": "How much a member may spend each month, across every feature." + }, + "daily": { + "title": "Daily count", + "desc": "How many times a day one feature may run per member." + }, + "budget": { + "title": "Site spending", + "desc": "The cap for the whole site, in USD.", + "link": "Change it in settings" + } + }, + "roles": { + "title": "Roles", + "desc": "A member gets the most generous allowance and the broadest permissions of all their roles.", + "no_features": "No AI feature members can use is registered yet, so there's nothing to grant.", + "root": "Root", + "features_allowed": "{allowed} of {total} features allowed", + "edit": "Edit access", + "edit_title": "AI access for {name}", + "edit_desc": "Monthly points and which AI features this role may use." + }, + "role_form": { + "tabs": { + "allowance": "Allowance", + "features": "Features" + }, + "unlimited": "Unlimited points", + "unlimited_desc": "Members with this role are never stopped by their monthly allowance. The site budget still applies.", + "monthly_points": "Monthly points", + "monthly_points_desc": "Points every member with this role gets each month.", + "site_default": "Use the site default", + "points_unit": "points", + "or": "or", + "grant": { + "default_allowed": "Default (allowed)", + "default_denied": "Default (not allowed)", + "allow": "Allow", + "deny": "Deny" + }, + "daily_limit": "Daily limit", + "action_default": "Use the action's limit", + "per_day": "runs / day", + "submit": "Save access", + "saved": { + "title": "Access saved", + "desc": "{name} has its new AI access." + } + }, + "users": { + "title": "User exceptions", + "desc": "One member's own rule, ahead of their roles: block them, lift the cap or set a different allowance.", + "add": "Add exception", + "add_title": "Add an exception", + "edit": "Edit exception", + "edit_title": "Exception for {name}", + "form_desc": "This replaces what the member's roles give them until it's removed.", + "empty": "No exceptions - everyone follows their roles.", + "blocked": "Blocked", + "remove": { + "title": "Remove exception", + "desc": "{name} goes back to what their roles allow.", + "cancel": "Cancel", + "confirm": "Remove", + "success": "Exception removed", + "success_desc": "{name} follows their roles again." + } + }, + "user_form": { + "user": "Member", + "user_required": "Choose a member.", + "blocked": "Block AI features", + "blocked_desc": "This member can't use any AI feature, whatever their roles allow.", + "unlimited": "Unlimited points", + "unlimited_desc": "Never stopped by a monthly allowance. The site budget still applies.", + "monthly_points": "Monthly points", + "monthly_points_desc": "A personal allowance instead of the one from their roles.", + "from_roles": "Use their roles", + "points_unit": "points", + "or": "or", + "submit": "Save exception", + "saved": { + "title": "Exception saved", + "desc": "It applies to the member's next AI operation." + } + } + }, + "history": { + "title": "AI history", + "desc": "Every AI operation: who ran it, which model answered and what it cost.", + "charged_reservation": "Charged {amount} (reserved)", + "filters": { + "all": "All", + "status": "Status", + "actor": "Origin", + "action": "Action", + "model": "Model" + }, + "list": { + "action": "Action", + "status": "Status", + "actor": "By", + "model": "Model", + "cost": "Cost", + "points": "Points", + "created": "When", + "details": "Details" + }, + "empty": { + "title": "No AI operations", + "desc": "Nothing matches these filters yet." + }, + "detail": { + "title": "Run #{id}", + "status": "Status", + "actor": "By", + "model": "Model", + "cost": "Cost", + "cost_source": "Cost source", + "charged": "Charged", + "reserved": "Reserved", + "points_unit": "pts", + "settlement": "Settlement", + "tokens": "Tokens in / out", + "created": "Started", + "finished": "Finished", + "prompt_version": "Prompt version", + "resource": "Resource", + "error": "Error", + "accepted": "Suggestion", + "accepted_yes": "Accepted", + "accepted_no": "Not accepted", + "privacy": "Prompts and outputs are never stored - only what it took and what it cost.", + "calls": { + "title": "Provider calls", + "empty": "No provider was called.", + "attempt": "Attempt", + "model": "Model", + "status": "Status", + "tokens": "Tokens in / out", + "cost": "Cost" + }, + "adjustments": { + "title": "Cost corrections", + "change": "{from} → {to}" + } + } + } } - } + }, + "@vitnode/core:files:can_edit_alt": "Edit image ALT text" } diff --git a/packages/vitnode/src/pages/admin/ai/access.tsx b/packages/vitnode/src/pages/admin/ai/access.tsx new file mode 100644 index 000000000..917f7c10f --- /dev/null +++ b/packages/vitnode/src/pages/admin/ai/access.tsx @@ -0,0 +1,24 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminAiRouteData } from "@/tanstack/admin/ai/route"; + +import { AdminAiAccessRouteContent } from "@/tanstack/admin/ai/access-screen"; +import { loadAdminAiAccessRoute } from "@/tanstack/admin/ai/route"; +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminAiAccessPage = ({ + loaderData, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute({ + // `head` after `load`, always. + load: async ({ context, t }) => + await loadAdminAiAccessRoute({ ...context, t }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "ai", "access"] }), +}); + +export default AdminAiAccessPage; diff --git a/packages/vitnode/src/pages/admin/ai/actions.tsx b/packages/vitnode/src/pages/admin/ai/actions.tsx new file mode 100644 index 000000000..e4abdb109 --- /dev/null +++ b/packages/vitnode/src/pages/admin/ai/actions.tsx @@ -0,0 +1,24 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminAiRouteData } from "@/tanstack/admin/ai/route"; + +import { AdminAiActionsRouteContent } from "@/tanstack/admin/ai/actions-screen"; +import { loadAdminAiActionsRoute } from "@/tanstack/admin/ai/route"; +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminAiActionsPage = ({ + loaderData, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute({ + // `head` after `load`, always. + load: async ({ context, t }) => + await loadAdminAiActionsRoute({ ...context, t }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "ai", "actions"] }), +}); + +export default AdminAiActionsPage; diff --git a/packages/vitnode/src/pages/admin/ai/history.tsx b/packages/vitnode/src/pages/admin/ai/history.tsx new file mode 100644 index 000000000..e26895c18 --- /dev/null +++ b/packages/vitnode/src/pages/admin/ai/history.tsx @@ -0,0 +1,39 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminAiHistoryRouteData } from "@/tanstack/admin/ai/route"; +import type { AiHistoryRouteSearch } from "@/tanstack/admin/ai/route-search"; + +import { AdminAiHistoryRouteContent } from "@/tanstack/admin/ai/history-screen"; +import { loadAdminAiHistoryRoute } from "@/tanstack/admin/ai/route"; +import { aiHistoryRouteParams } from "@/tanstack/admin/ai/route-search"; +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminAiHistoryPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute< + AdminAiHistoryRouteData, + AiHistoryRouteSearch +>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminAiHistoryRoute({ + ...context, + params: aiHistoryRouteParams(search), + t, + }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "ai", "history"] }), +}); + +export default AdminAiHistoryPage; diff --git a/packages/vitnode/src/pages/admin/ai/index.tsx b/packages/vitnode/src/pages/admin/ai/index.tsx new file mode 100644 index 000000000..78d4417f1 --- /dev/null +++ b/packages/vitnode/src/pages/admin/ai/index.tsx @@ -0,0 +1,34 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminAiOverviewRouteData } from "@/tanstack/admin/ai/route"; +import type { AiOverviewRouteSearch } from "@/tanstack/admin/ai/route-search"; + +import { AdminAiOverviewRouteContent } from "@/tanstack/admin/ai/overview-screen"; +import { loadAdminAiOverviewRoute } from "@/tanstack/admin/ai/route"; +import { aiOverviewPeriodOf } from "@/tanstack/admin/ai/route-search"; +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminAiOverviewPage = ({ + loaderData, + navigate, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute< + AdminAiOverviewRouteData, + AiOverviewRouteSearch +>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminAiOverviewRoute({ + ...context, + period: aiOverviewPeriodOf(search), + t, + }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "ai"] }), +}); + +export default AdminAiOverviewPage; diff --git a/packages/vitnode/src/pages/admin/ai/models.tsx b/packages/vitnode/src/pages/admin/ai/models.tsx new file mode 100644 index 000000000..95d47606e --- /dev/null +++ b/packages/vitnode/src/pages/admin/ai/models.tsx @@ -0,0 +1,24 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminAiRouteData } from "@/tanstack/admin/ai/route"; + +import { AdminAiModelsRouteContent } from "@/tanstack/admin/ai/models-screen"; +import { loadAdminAiModelsRoute } from "@/tanstack/admin/ai/route"; +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminAiModelsPage = ({ + loaderData, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute({ + // `head` after `load`, always. + load: async ({ context, t }) => + await loadAdminAiModelsRoute({ ...context, t }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "ai", "models"] }), +}); + +export default AdminAiModelsPage; diff --git a/packages/vitnode/src/pages/admin/ai/settings.tsx b/packages/vitnode/src/pages/admin/ai/settings.tsx new file mode 100644 index 000000000..302ce296c --- /dev/null +++ b/packages/vitnode/src/pages/admin/ai/settings.tsx @@ -0,0 +1,24 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminAiRouteData } from "@/tanstack/admin/ai/route"; + +import { loadAdminAiSettingsRoute } from "@/tanstack/admin/ai/route"; +import { AdminAiSettingsRouteContent } from "@/tanstack/admin/ai/settings-screen"; +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminAiSettingsPage = ({ + loaderData, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute({ + // `head` after `load`, always. + load: async ({ context, t }) => + await loadAdminAiSettingsRoute({ ...context, t }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "ai", "settings"] }), +}); + +export default AdminAiSettingsPage; diff --git a/packages/vitnode/src/pages/settings/ai.tsx b/packages/vitnode/src/pages/settings/ai.tsx new file mode 100644 index 000000000..bec8783a6 --- /dev/null +++ b/packages/vitnode/src/pages/settings/ai.tsx @@ -0,0 +1,43 @@ +import type { PluginRoutePageProps } from "@/routing"; + +import { defineAuthenticatedRoute } from "@/tanstack/plugin-routes"; +import { AiUsagePanelContent } from "@/tanstack/settings-ai/panel"; +import { settingsBreadcrumb } from "@/tanstack/settings/breadcrumb"; +import { + aiHistoryQueryOptions, + aiUsageQueryOptions, +} from "@/views/auth/settings/ai/ai-usage-query"; + +interface AiUsageData { + userId: number; +} + +const AiUsagePage = ({ loaderData }: PluginRoutePageProps) => ( + +); + +export const route = defineAuthenticatedRoute({ + load: async ({ context }) => { + const userId = context.auth.user.id; + + await Promise.all([ + context.queryClient.query({ + ...aiUsageQueryOptions({ userId }), + staleTime: "static", + }), + context.queryClient.infiniteQuery({ + ...aiHistoryQueryOptions({ userId }), + staleTime: "static", + }), + ]); + + return { userId }; + }, + head: ({ t }) => ({ + title: `${t("core.auth.settings.nav.ai")} - ${t("core.auth.settings.title")}`, + }), + + breadcrumb: settingsBreadcrumb("ai"), +}); + +export default AiUsagePage; diff --git a/packages/vitnode/src/routes.test.ts b/packages/vitnode/src/routes.test.ts index 6f62f1796..c0ffb62af 100644 --- a/packages/vitnode/src/routes.test.ts +++ b/packages/vitnode/src/routes.test.ts @@ -130,6 +130,8 @@ describe("core's route tree", () => { expect(declared).toContain("@vitnode/core:page#/admin/content/*"); for (const path of [ + "/admin/core/ai", + "/admin/core/ai/history", "/admin/core/advanced/cron", "/admin/core/advanced/queue", "/admin/core/system/files", diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index c21b8d03a..14f456d01 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -1,4 +1,9 @@ import { defineRoutes, index, layout, lazy, page } from "./routing"; +import { ADMIN_AI_NAMESPACES } from "./tanstack/admin/ai/route"; +import { + normalizeAiHistoryRouteSearch, + normalizeAiOverviewSearch, +} from "./tanstack/admin/ai/route-search"; import { contentListRouteSearch } from "./tanstack/admin/content/route-search"; import { ADMIN_CRON_NAMESPACES } from "./tanstack/admin/cron/route"; import { normalizeCronRouteSearch } from "./tanstack/admin/cron/route-search"; @@ -158,6 +163,11 @@ export const routes = defineRoutes([ pendingComponent: FormPendingSkeleton, search: normalizeSsoSettingsSearch, }), + + page("ai", { + component: lazy(() => import("./pages/settings/ai")), + pendingComponent: () => , + }), ], }), @@ -187,6 +197,50 @@ export const routes = defineRoutes([ search: normalizeAdminSignInSearch, }), + page("/admin/core/ai", { + area: "admin", + component: lazy(() => import("./pages/admin/ai/index")), + messages: ADMIN_AI_NAMESPACES, + pendingComponent: CardsPendingSkeleton, + search: normalizeAiOverviewSearch, + }), + + page("/admin/core/ai/models", { + area: "admin", + component: lazy(() => import("./pages/admin/ai/models")), + messages: ADMIN_AI_NAMESPACES, + pendingComponent: CardsPendingSkeleton, + }), + + page("/admin/core/ai/actions", { + area: "admin", + component: lazy(() => import("./pages/admin/ai/actions")), + messages: ADMIN_AI_NAMESPACES, + pendingComponent: CardsPendingSkeleton, + }), + + page("/admin/core/ai/access", { + area: "admin", + component: lazy(() => import("./pages/admin/ai/access")), + messages: ADMIN_AI_NAMESPACES, + pendingComponent: CardsPendingSkeleton, + }), + + page("/admin/core/ai/history", { + area: "admin", + component: lazy(() => import("./pages/admin/ai/history")), + messages: ADMIN_AI_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeAiHistoryRouteSearch, + }), + + page("/admin/core/ai/settings", { + area: "admin", + component: lazy(() => import("./pages/admin/ai/settings")), + messages: ADMIN_AI_NAMESPACES, + pendingComponent: FormPendingSkeleton, + }), + page("/admin/core/advanced/cron", { area: "admin", component: lazy(() => import("./pages/admin/advanced/cron")), diff --git a/packages/vitnode/src/tanstack/admin/ai/access-screen.tsx b/packages/vitnode/src/tanstack/admin/ai/access-screen.tsx new file mode 100644 index 000000000..dd1a06b79 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/ai/access-screen.tsx @@ -0,0 +1,70 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import React from "react"; + +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { PageTitle } from "@/components/ui/page-title"; +import { AiAccessContent } from "@/views/admin/views/core/ai/access/access-content"; +import { ADMIN_AI_PERMISSIONS } from "@/views/admin/views/core/ai/ai-permissions"; + +import type { AdminAiRouteData } from "./route"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { readAdminUserSearch } from "../user-search"; +import { + adminAiAccessQuery, + adminAiActionsQuery, + useAdminAiMutations, +} from "./query"; +import { ADMIN_AI_NAMESPACES } from "./route"; + +export const AdminAiAccessRouteContent = ({ + adminUserId, + description, + title, +}: AdminAiRouteData) => { + const { data } = useSuspenseQuery(adminAiAccessQuery({ adminUserId })); + const { data: actionsData } = useSuspenseQuery( + adminAiActionsQuery({ adminUserId }), + ); + const { deleteUserOverride, updateRoleAccess, updateUserOverride } = + useAdminAiMutations(); + const canManage = useAdminStaffPermission(ADMIN_AI_PERMISSIONS.manage); + + // A permission is named by what its actions do; its key is the fallback. + const permissionNames = React.useMemo(() => { + const descriptions = new Map( + actionsData.actions.map(action => [action.key, action.description]), + ); + + return new Map( + data.permissions.map(permission => { + const names = permission.actions + .map(key => descriptions.get(key)) + .filter((name): name is string => Boolean(name)); + + return [ + permission.key, + names.length > 0 ? names.join(", ") : permission.key, + ]; + }), + ); + }, [actionsData.actions, data.permissions]); + + return ( + +
+ + + permissionNames.get(key) ?? key} + onDeleteUserOverride={deleteUserOverride} + onSaveRole={updateRoleAccess} + onSaveUserOverride={updateUserOverride} + searchUsers={readAdminUserSearch} + /> +
+
+ ); +}; diff --git a/packages/vitnode/src/tanstack/admin/ai/actions-screen.tsx b/packages/vitnode/src/tanstack/admin/ai/actions-screen.tsx new file mode 100644 index 000000000..38846b95e --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/ai/actions-screen.tsx @@ -0,0 +1,45 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; + +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { PageTitle } from "@/components/ui/page-title"; +import { AiActionsContent } from "@/views/admin/views/core/ai/actions/actions-content"; +import { ADMIN_AI_PERMISSIONS } from "@/views/admin/views/core/ai/ai-permissions"; + +import type { AdminAiRouteData } from "./route"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { + adminAiActionsQuery, + adminAiModelsQuery, + useAdminAiMutations, +} from "./query"; +import { ADMIN_AI_NAMESPACES } from "./route"; + +export const AdminAiActionsRouteContent = ({ + adminUserId, + description, + title, +}: AdminAiRouteData) => { + const { data } = useSuspenseQuery(adminAiActionsQuery({ adminUserId })); + const { data: modelsData } = useSuspenseQuery( + adminAiModelsQuery({ adminUserId }), + ); + const { testAction, updateAction } = useAdminAiMutations(); + const canManage = useAdminStaffPermission(ADMIN_AI_PERMISSIONS.manage); + + return ( + +
+ + + +
+
+ ); +}; diff --git a/packages/vitnode/src/tanstack/admin/ai/history-screen.tsx b/packages/vitnode/src/tanstack/admin/ai/history-screen.tsx new file mode 100644 index 000000000..917cd4cf8 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/ai/history-screen.tsx @@ -0,0 +1,100 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import React from "react"; + +import type { DataTableNavigation } from "@/components/table/navigation"; +import type { AiHistoryFilters } from "@/views/admin/views/core/ai/ai-query"; + +import { DataTableNavigationProvider } from "@/components/table/navigation"; +import { PageTitle } from "@/components/ui/page-title"; +import { AiHistoryTableContent } from "@/views/admin/views/core/ai/history/history-table-content"; + +import type { AdminTableNavigate } from "../table-search"; +import type { AdminAiHistoryRouteData } from "./route"; +import type { + AiHistoryRouteSearch, + UncheckedAiHistorySearch, +} from "./route-search"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { + adminAiActionsQuery, + adminAiHistoryQuery, + adminAiModelsQuery, + adminAiRunQuery, +} from "./query"; +import { ADMIN_AI_NAMESPACES } from "./route"; +import { + aiHistorySearchFrom, + aiHistorySearchParams, + normalizeAiHistoryRouteSearch, +} from "./route-search"; + +export interface AdminAiHistoryRouteProps extends AdminAiHistoryRouteData { + navigate: AdminTableNavigate; + search: UncheckedAiHistorySearch; +} + +export const AdminAiHistoryRouteContent = ({ + adminUserId, + description, + navigate, + params, + search, + title, +}: AdminAiHistoryRouteProps) => { + const { data } = useSuspenseQuery( + adminAiHistoryQuery({ adminUserId, params }), + ); + const { data: actionsData } = useSuspenseQuery( + adminAiActionsQuery({ adminUserId }), + ); + const { data: modelsData } = useSuspenseQuery( + adminAiModelsQuery({ adminUserId }), + ); + + const navigation = React.useMemo( + () => ({ + navigate: async nextSearch => { + await navigate({ + resetScroll: false, + search: aiHistorySearchFrom(nextSearch), + }); + }, + searchParams: aiHistorySearchParams(search), + }), + [navigate, search], + ); + + const { action, actorType, first, modelId, order, status } = + normalizeAiHistoryRouteSearch(search); + const filters: AiHistoryFilters = { action, actorType, modelId, status }; + + return ( + +
+ + + + { + // A new filter starts from the first page. + void navigate({ + resetScroll: false, + search: normalizeAiHistoryRouteSearch({ + ...next, + first, + order, + }), + }); + }} + runQuery={id => adminAiRunQuery({ adminUserId, id })} + /> + +
+
+ ); +}; diff --git a/packages/vitnode/src/tanstack/admin/ai/index.ts b/packages/vitnode/src/tanstack/admin/ai/index.ts new file mode 100644 index 000000000..1cee73063 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/ai/index.ts @@ -0,0 +1,54 @@ +export { AdminAiAccessRouteContent } from "./access-screen"; +export { AdminAiActionsRouteContent } from "./actions-screen"; +export { AdminAiHistoryRouteContent } from "./history-screen"; +export { AdminAiModelsRouteContent } from "./models-screen"; +export { AdminAiOverviewRouteContent } from "./overview-screen"; +export { + adminAiAccessQuery, + adminAiActionsQuery, + adminAiHistoryQuery, + adminAiModelsQuery, + adminAiOverviewQuery, + adminAiRunQuery, + adminAiSettingsQuery, + invalidateAdminAi, + useAdminAiMutations, +} from "./query"; +export type { AdminAiMutations } from "./query"; +export type { + AdminAiHistoryRouteData, + AdminAiOverviewRouteData, + AdminAiRouteData, +} from "./route"; +export { + ADMIN_AI_NAMESPACES, + loadAdminAiAccessRoute, + loadAdminAiActionsRoute, + loadAdminAiHistoryRoute, + loadAdminAiModelsRoute, + loadAdminAiOverviewRoute, + loadAdminAiSettingsRoute, +} from "./route"; +export type { + AiHistoryRouteSearch, + AiOverviewRouteSearch, + UncheckedAiHistorySearch, +} from "./route-search"; +export { + aiHistoryRouteParams, + aiHistorySearchFrom, + aiHistorySearchParams, + normalizeAiHistoryRouteSearch, + normalizeAiOverviewSearch, +} from "./route-search"; +export { AdminAiSettingsRouteContent } from "./settings-screen"; + +export type { + AdminAiAccess, + AdminAiAction, + AdminAiModel, + AdminAiOverview, + AdminAiRunDetail, + AdminAiRunRow, + AdminAiSettings, +} from "@/views/admin/views/core/ai/ai-query"; diff --git a/packages/vitnode/src/tanstack/admin/ai/models-screen.tsx b/packages/vitnode/src/tanstack/admin/ai/models-screen.tsx new file mode 100644 index 000000000..261184c90 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/ai/models-screen.tsx @@ -0,0 +1,47 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; + +import { + AdminStaffPermissionGate, + useAdminStaffPermission, +} from "@/components/staff-permission/provider"; +import { PageTitle } from "@/components/ui/page-title"; +import { ADMIN_AI_PERMISSIONS } from "@/views/admin/views/core/ai/ai-permissions"; +import { + AiModelsContent, + AiSyncPricingButton, +} from "@/views/admin/views/core/ai/models/models-content"; + +import type { AdminAiRouteData } from "./route"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { adminAiModelsQuery, useAdminAiMutations } from "./query"; +import { ADMIN_AI_NAMESPACES } from "./route"; + +export const AdminAiModelsRouteContent = ({ + adminUserId, + description, + title, +}: AdminAiRouteData) => { + const { data } = useSuspenseQuery(adminAiModelsQuery({ adminUserId })); + const { deletePricing, syncPricing, updatePricing } = useAdminAiMutations(); + const canManage = useAdminStaffPermission(ADMIN_AI_PERMISSIONS.manage); + + return ( + +
+ + + + + + + +
+
+ ); +}; diff --git a/packages/vitnode/src/tanstack/admin/ai/overview-screen.tsx b/packages/vitnode/src/tanstack/admin/ai/overview-screen.tsx new file mode 100644 index 000000000..463aaa0f9 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/ai/overview-screen.tsx @@ -0,0 +1,97 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { AiOverviewPeriod } from "@/views/admin/views/core/ai/ai-query"; + +import { Button } from "@/components/ui/button"; +import { PageTitle } from "@/components/ui/page-title"; +import { AI_OVERVIEW_PERIODS } from "@/views/admin/views/core/ai/ai-query"; +import { AiOverviewContent } from "@/views/admin/views/core/ai/overview/overview-content"; + +import type { AdminAiOverviewRouteData } from "./route"; +import type { AiOverviewRouteSearch } from "./route-search"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { adminAiActionsQuery, adminAiOverviewQuery } from "./query"; +import { ADMIN_AI_NAMESPACES } from "./route"; + +export interface AdminAiOverviewRouteProps extends AdminAiOverviewRouteData { + navigate: (options: { + resetScroll: boolean; + search: AiOverviewRouteSearch; + }) => Promise; +} + +const PeriodSwitch = ({ + onChange, + period, +}: { + onChange: (period: AiOverviewPeriod) => void; + period: AiOverviewPeriod; +}) => { + const t = useTranslations("admin.ai.overview.period"); + + return ( +
+ {AI_OVERVIEW_PERIODS.map(value => ( + + ))} +
+ ); +}; + +export const AdminAiOverviewRouteContent = ({ + adminUserId, + description, + navigate, + period, + title, +}: AdminAiOverviewRouteProps) => { + const { data } = useSuspenseQuery( + adminAiOverviewQuery({ adminUserId, period }), + ); + const { data: actionsData } = useSuspenseQuery( + adminAiActionsQuery({ adminUserId }), + ); + const descriptions = React.useMemo( + () => + new Map( + actionsData.actions.map(action => [action.key, action.description]), + ), + [actionsData.actions], + ); + + return ( + +
+ + { + void navigate({ + resetScroll: false, + search: next === "previous" ? { period: "previous" } : {}, + }); + }} + period={period} + /> + + + descriptions.get(key) ?? null} + /> +
+
+ ); +}; diff --git a/packages/vitnode/src/tanstack/admin/ai/query.ts b/packages/vitnode/src/tanstack/admin/ai/query.ts new file mode 100644 index 000000000..3c07f46ae --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/ai/query.ts @@ -0,0 +1,100 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { useQueryClient } from "@tanstack/react-query"; +import React from "react"; + +import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; +import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; + +import { + deleteAdminAiPricing, + deleteAdminAiUserOverride, + syncAdminAiPricing, + testAdminAiAction, + updateAdminAiAction, + updateAdminAiPricing, + updateAdminAiRoleAccess, + updateAdminAiSettings, + updateAdminAiUserOverride, +} from "@/views/admin/views/core/ai/ai-mutations"; +import { + adminAiAccessQueryOptions, + adminAiActionsQueryOptions, + adminAiHistoryQueryOptions, + adminAiModelsQueryOptions, + adminAiOverviewQueryOptions, + adminAiQueryRoot, + adminAiRunQueryOptions, + adminAiSettingsQueryOptions, +} from "@/views/admin/views/core/ai/ai-query"; + +import { useAdminIdentity } from "../identity"; + +export const adminAiOverviewQuery = adminAiOverviewQueryOptions; +export const adminAiSettingsQuery = adminAiSettingsQueryOptions; +export const adminAiModelsQuery = adminAiModelsQueryOptions; +export const adminAiActionsQuery = adminAiActionsQueryOptions; +export const adminAiAccessQuery = adminAiAccessQueryOptions; +export const adminAiHistoryQuery = adminAiHistoryQueryOptions; +export const adminAiRunQuery = adminAiRunQueryOptions; + +/** + * Every AI screen reads from the others' data - a price change moves the + * overview's estimates, a test run lands in history - so one write expires + * them all. + */ +export const invalidateAdminAi = async ( + queryClient: QueryClient, + adminUserId: AdminIdentity, +): Promise => + await queryClient.invalidateQueries({ + queryKey: adminAiQueryRoot(adminUserId), + }); + +type Mutation = ( + ...args: TArgs +) => Promise>; + +export interface AdminAiMutations { + deletePricing: typeof deleteAdminAiPricing; + deleteUserOverride: typeof deleteAdminAiUserOverride; + syncPricing: typeof syncAdminAiPricing; + testAction: typeof testAdminAiAction; + updateAction: typeof updateAdminAiAction; + updatePricing: typeof updateAdminAiPricing; + updateRoleAccess: typeof updateAdminAiRoleAccess; + updateSettings: typeof updateAdminAiSettings; + updateUserOverride: typeof updateAdminAiUserOverride; +} + +export const useAdminAiMutations = (): AdminAiMutations => { + const queryClient = useQueryClient(); + const adminUserId = useAdminIdentity(); + + return React.useMemo(() => { + const settled = + ( + mutation: Mutation, + ): Mutation => + async (...args) => { + const result = await mutation(...args); + if ("data" in result) { + await invalidateAdminAi(queryClient, adminUserId); + } + + return result; + }; + + return { + deletePricing: settled(deleteAdminAiPricing), + deleteUserOverride: settled(deleteAdminAiUserOverride), + syncPricing: settled(syncAdminAiPricing), + testAction: settled(testAdminAiAction), + updateAction: settled(updateAdminAiAction), + updatePricing: settled(updateAdminAiPricing), + updateRoleAccess: settled(updateAdminAiRoleAccess), + updateSettings: settled(updateAdminAiSettings), + updateUserOverride: settled(updateAdminAiUserOverride), + }; + }, [adminUserId, queryClient]); +}; diff --git a/packages/vitnode/src/tanstack/admin/ai/route-search.ts b/packages/vitnode/src/tanstack/admin/ai/route-search.ts new file mode 100644 index 000000000..d9a46f325 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/ai/route-search.ts @@ -0,0 +1,131 @@ +import type { + AiHistoryFilters, + AiHistoryOrderBy, + AiHistoryParams, + AiOverviewPeriod, +} from "@/views/admin/views/core/ai/ai-query"; + +import { isAiRunStatus } from "@/lib/ai/run-status"; +import { asSearchValue } from "@/lib/table-params"; +import { + AI_HISTORY_ACTOR_TYPES, + AI_HISTORY_TABLE_CONTRACT, +} from "@/views/admin/views/core/ai/ai-query"; + +import type { + AdminTableRouteSearch, + UncheckedAdminTableSearch, +} from "../table-search"; + +import { + adminTableRouteParams, + normalizeAdminTableSearch, +} from "../table-search"; + +/** `/admin/core/ai`: the current month is the URL saying nothing. */ +export interface AiOverviewRouteSearch { + period?: Exclude; +} + +export const normalizeAiOverviewSearch = ( + input: Record, +): AiOverviewRouteSearch => + asSearchValue(input.period) === "previous" ? { period: "previous" } : {}; + +export const aiOverviewPeriodOf = ( + search: AiOverviewRouteSearch, +): AiOverviewPeriod => search.period ?? "current"; + +export type AiHistoryRouteSearch = AiHistoryFilters & + Omit< + AdminTableRouteSearch, + "orderBy" | "search" | "status" + >; + +export type UncheckedAiHistorySearch = + AiHistoryRouteSearch | Record; + +const KEY_PATTERN = /^[\w@:./-]{1,255}$/; + +/** The history route's own filters, one value each - the API takes no lists. */ +const readHistoryFilters = ( + input: UncheckedAiHistorySearch, +): AiHistoryFilters => { + const action = asSearchValue(input.action); + const actorType = asSearchValue(input.actorType); + const modelId = asSearchValue(input.modelId); + const status = asSearchValue(input.status); + + return { + ...(action && KEY_PATTERN.test(action) ? { action } : {}), + ...(AI_HISTORY_ACTOR_TYPES.find(value => value === actorType) + ? { actorType: actorType as AiHistoryFilters["actorType"] } + : {}), + ...(modelId && KEY_PATTERN.test(modelId) ? { modelId } : {}), + ...(status && isAiRunStatus(status) ? { status } : {}), + }; +}; + +/** Pagination without the parts this list has no use for. */ +const tableSearchOf = (input: UncheckedAiHistorySearch) => { + const { cursor, first, last, order, page } = normalizeAdminTableSearch( + input as UncheckedAdminTableSearch, + AI_HISTORY_TABLE_CONTRACT, + ); + + return { + ...(cursor === undefined ? {} : { cursor }), + ...(page === undefined ? {} : { page }), + ...(first === undefined ? {} : { first }), + ...(last === undefined ? {} : { last }), + ...(order === undefined ? {} : { order }), + }; +}; + +/** The route's `validateSearch`: it normalises rather than rejects. */ +export const normalizeAiHistoryRouteSearch = ( + input: UncheckedAiHistorySearch, +): AiHistoryRouteSearch => ({ + ...tableSearchOf(input), + ...readHistoryFilters(input), +}); + +/** The request this URL is asking for, and therefore the query key. */ +export const aiHistoryRouteParams = ( + input: UncheckedAiHistorySearch, +): AiHistoryParams => { + const { cursor, first, last, order, page } = adminTableRouteParams( + input as UncheckedAdminTableSearch, + AI_HISTORY_TABLE_CONTRACT, + ); + + return { + ...(cursor === undefined ? {} : { cursor }), + ...(first === undefined ? {} : { first }), + ...(last === undefined ? {} : { last }), + ...(order === undefined ? {} : { order }), + ...(page === undefined ? {} : { page }), + ...readHistoryFilters(input), + }; +}; + +/** The query string the table's controls read themselves out of. */ +export const aiHistorySearchParams = ( + input: UncheckedAiHistorySearch, +): URLSearchParams => { + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries( + normalizeAiHistoryRouteSearch(input), + )) { + params.set(key, String(value)); + } + + return params; +}; + +/** A query string one of those controls produced, back as route search. */ +export const aiHistorySearchFrom = (nextSearch: string): AiHistoryRouteSearch => + normalizeAiHistoryRouteSearch( + Object.fromEntries(new URLSearchParams(nextSearch)), + ); diff --git a/packages/vitnode/src/tanstack/admin/ai/route.tsx b/packages/vitnode/src/tanstack/admin/ai/route.tsx new file mode 100644 index 000000000..c76fd216b --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/ai/route.tsx @@ -0,0 +1,186 @@ +import type { PluginRouteTranslator } from "@/routing"; +import type { + AiHistoryParams, + AiOverviewPeriod, +} from "@/views/admin/views/core/ai/ai-query"; +import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; + +import { ADMIN_AI_PERMISSIONS } from "@/views/admin/views/core/ai/ai-permissions"; + +import type { AdminScreenContext } from "../screen"; + +import { adminIdentityOf } from "../identity"; +import { requireAdminPermission } from "../screen"; +import { + adminAiAccessQuery, + adminAiActionsQuery, + adminAiHistoryQuery, + adminAiModelsQuery, + adminAiOverviewQuery, + adminAiSettingsQuery, +} from "./query"; + +/** + * `/admin/core/ai/*`, as everything a TanStack Start route needs and nothing + * a route owns. Every screen reads `ai:can_view`; writes are gated on + * `ai:can_manage` in the screens themselves. + */ + +export const ADMIN_AI_NAMESPACES = ["admin.ai", "core.global"] as const; + +export interface AdminAiRouteData { + adminUserId: AdminIdentity; + description: string; + title: string; +} + +export interface AdminAiOverviewRouteData extends AdminAiRouteData { + period: AiOverviewPeriod; +} + +export interface AdminAiHistoryRouteData extends AdminAiRouteData { + params: AiHistoryParams; +} + +type AdminAiLoaderContext = AdminScreenContext & { t: PluginRouteTranslator }; + +const STATIC = { staleTime: "static" } as const; + +/** The permission check and the identity every AI screen starts from. */ +const enter = ({ adminAccess }: AdminAiLoaderContext): AdminIdentity => { + requireAdminPermission(adminAccess, ADMIN_AI_PERMISSIONS.view); + + return adminIdentityOf(adminAccess); +}; + +export const loadAdminAiOverviewRoute = async ( + context: AdminAiLoaderContext & { period: AiOverviewPeriod }, +): Promise => { + const adminUserId = enter(context); + + await Promise.all([ + context.queryClient.query({ + ...adminAiOverviewQuery({ adminUserId, period: context.period }), + ...STATIC, + }), + context.queryClient.query({ + ...adminAiActionsQuery({ adminUserId }), + ...STATIC, + }), + ]); + + return { + adminUserId, + description: context.t("admin.ai.overview.desc"), + period: context.period, + title: context.t("admin.ai.overview.title"), + }; +}; + +export const loadAdminAiSettingsRoute = async ( + context: AdminAiLoaderContext, +): Promise => { + const adminUserId = enter(context); + + await context.queryClient.query({ + ...adminAiSettingsQuery({ adminUserId }), + ...STATIC, + }); + + return { + adminUserId, + description: context.t("admin.ai.settings.desc"), + title: context.t("admin.ai.settings.title"), + }; +}; + +export const loadAdminAiModelsRoute = async ( + context: AdminAiLoaderContext, +): Promise => { + const adminUserId = enter(context); + + await context.queryClient.query({ + ...adminAiModelsQuery({ adminUserId }), + ...STATIC, + }); + + return { + adminUserId, + description: context.t("admin.ai.models.desc"), + title: context.t("admin.ai.models.title"), + }; +}; + +export const loadAdminAiActionsRoute = async ( + context: AdminAiLoaderContext, +): Promise => { + const adminUserId = enter(context); + + await Promise.all([ + context.queryClient.query({ + ...adminAiActionsQuery({ adminUserId }), + ...STATIC, + }), + context.queryClient.query({ + ...adminAiModelsQuery({ adminUserId }), + ...STATIC, + }), + ]); + + return { + adminUserId, + description: context.t("admin.ai.actions.desc"), + title: context.t("admin.ai.actions.title"), + }; +}; + +export const loadAdminAiAccessRoute = async ( + context: AdminAiLoaderContext, +): Promise => { + const adminUserId = enter(context); + + await Promise.all([ + context.queryClient.query({ + ...adminAiAccessQuery({ adminUserId }), + ...STATIC, + }), + context.queryClient.query({ + ...adminAiActionsQuery({ adminUserId }), + ...STATIC, + }), + ]); + + return { + adminUserId, + description: context.t("admin.ai.access.desc"), + title: context.t("admin.ai.access.title"), + }; +}; + +export const loadAdminAiHistoryRoute = async ( + context: AdminAiLoaderContext & { params: AiHistoryParams }, +): Promise => { + const adminUserId = enter(context); + + await Promise.all([ + context.queryClient.query({ + ...adminAiHistoryQuery({ adminUserId, params: context.params }), + ...STATIC, + }), + context.queryClient.query({ + ...adminAiActionsQuery({ adminUserId }), + ...STATIC, + }), + context.queryClient.query({ + ...adminAiModelsQuery({ adminUserId }), + ...STATIC, + }), + ]); + + return { + adminUserId, + description: context.t("admin.ai.history.desc"), + params: context.params, + title: context.t("admin.ai.history.title"), + }; +}; diff --git a/packages/vitnode/src/tanstack/admin/ai/settings-screen.tsx b/packages/vitnode/src/tanstack/admin/ai/settings-screen.tsx new file mode 100644 index 000000000..57a6d2178 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/ai/settings-screen.tsx @@ -0,0 +1,48 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useTranslations } from "use-intl"; + +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { PageTitle } from "@/components/ui/page-title"; +import { ADMIN_AI_PERMISSIONS } from "@/views/admin/views/core/ai/ai-permissions"; +import { AiSettingsFormContent } from "@/views/admin/views/core/ai/settings/settings-form-content"; + +import type { AdminAiRouteData } from "./route"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { adminAiSettingsQuery, useAdminAiMutations } from "./query"; +import { ADMIN_AI_NAMESPACES } from "./route"; + +const AiSettingsBody = ({ + adminUserId, +}: Pick) => { + const t = useTranslations("admin.ai.settings"); + const { data } = useSuspenseQuery(adminAiSettingsQuery({ adminUserId })); + const { updateSettings } = useAdminAiMutations(); + const canManage = useAdminStaffPermission(ADMIN_AI_PERMISSIONS.manage); + + return ( +
+

+ {t("time_zone", { timeZone: data.timeZone })} +

+ +
+ ); +}; + +export const AdminAiSettingsRouteContent = ({ + adminUserId, + description, + title, +}: AdminAiRouteData) => ( + +
+ + +
+
+); diff --git a/packages/vitnode/src/tanstack/settings-ai/index.ts b/packages/vitnode/src/tanstack/settings-ai/index.ts new file mode 100644 index 000000000..0a49e5293 --- /dev/null +++ b/packages/vitnode/src/tanstack/settings-ai/index.ts @@ -0,0 +1,14 @@ +export { AiUsagePanelContent } from "./panel"; + +export type { + AiHistoryItem, + AiHistoryPage, + AiUsage, + AiUsageAction, + AiUsageNotice, +} from "@/views/auth/settings/ai/ai-usage-query"; +export { + aiHistoryQueryOptions, + aiUsageQueryOptions, + aiUsageQueryRoot, +} from "@/views/auth/settings/ai/ai-usage-query"; diff --git a/packages/vitnode/src/tanstack/settings-ai/panel.tsx b/packages/vitnode/src/tanstack/settings-ai/panel.tsx new file mode 100644 index 000000000..008e487e6 --- /dev/null +++ b/packages/vitnode/src/tanstack/settings-ai/panel.tsx @@ -0,0 +1,48 @@ +import { + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from "@tanstack/react-query"; +import { useTranslations } from "use-intl"; + +import { PageTitle } from "@/components/ui/page-title"; +import { AiHistoryContent } from "@/views/auth/settings/ai/ai-history-content"; +import { AiUsageContent } from "@/views/auth/settings/ai/ai-usage-content"; +import { + aiHistoryQueryOptions, + aiUsageQueryOptions, +} from "@/views/auth/settings/ai/ai-usage-query"; + +const AiUsageHeading = () => { + const t = useTranslations("core.auth.settings.ai"); + const tSettings = useTranslations("core.auth.settings"); + + return ( + + ); +}; + +export const AiUsagePanelContent = ({ userId }: { userId: number }) => { + const { data: usage } = useSuspenseQuery(aiUsageQueryOptions({ userId })); + const history = useSuspenseInfiniteQuery(aiHistoryQueryOptions({ userId })); + + return ( + <> + + + page.items)} + onLoadMore={() => { + void history.fetchNextPage(); + }} + /> + + ); +}; diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts index 2618c1fc3..4c1044a89 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts @@ -70,6 +70,13 @@ describe("the core group", () => { "/admin/core/staff", "/admin/core/staff/moderators", "/admin/core/staff/admins", + "/admin/core/ai", + "/admin/core/ai", + "/admin/core/ai/models", + "/admin/core/ai/actions", + "/admin/core/ai/access", + "/admin/core/ai/history", + "/admin/core/ai/settings", "/admin/core/advanced", "/admin/core/advanced/search", "/admin/core/advanced/cron", diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx index 731a6c476..fdfa12374 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx @@ -3,6 +3,7 @@ import { LayoutDashboardIcon, ServerIcon, ShieldUserIcon, + SparklesIcon, UsersRoundIcon, WrenchIcon, } from "lucide-react"; @@ -218,6 +219,43 @@ const coreNavGroup = (): AdminNavGroupDeclaration => ({ }, ], }, + { + href: "/admin/core/ai", + title: key("admin.global.nav.ai.title"), + icon: , + items: [ + { + title: key("admin.global.nav.ai.overview"), + href: "/admin/core/ai", + permission: core("ai"), + }, + { + title: key("admin.global.nav.ai.models"), + href: "/admin/core/ai/models", + permission: core("ai"), + }, + { + title: key("admin.global.nav.ai.actions"), + href: "/admin/core/ai/actions", + permission: core("ai"), + }, + { + title: key("admin.global.nav.ai.access"), + href: "/admin/core/ai/access", + permission: core("ai"), + }, + { + title: key("admin.global.nav.ai.history"), + href: "/admin/core/ai/history", + permission: core("ai"), + }, + { + title: key("admin.global.nav.ai.settings"), + href: "/admin/core/ai/settings", + permission: core("ai"), + }, + ], + }, { href: "/admin/core/advanced", title: key("admin.global.nav.advanced.title"), diff --git a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx index a0fc44dbe..dc70321af 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx +++ b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx @@ -7,6 +7,7 @@ import type { ContentFileFieldValue, } from "@/content/files"; +import { AiFieldAssist } from "@/components/ai/ai-field-assist"; import { AutoFormCombobox } from "@/components/form/fields/combobox"; import { AutoFormDateTime } from "@/components/form/fields/date-time"; import { AutoFormFile } from "@/components/form/fields/file"; @@ -204,6 +205,9 @@ export const ContentField = ({ ...rest, otherProps: { ...rest.otherProps, isOptional }, }; + const aiAssist = spec.ai ? ( + + ) : undefined; switch (spec.kind) { case "boolean": @@ -255,6 +259,7 @@ export const ContentField = ({ return ( + ); } }; diff --git a/packages/vitnode/src/views/admin/views/core/ai/access/access-content.tsx b/packages/vitnode/src/views/admin/views/core/ai/access/access-content.tsx new file mode 100644 index 000000000..21ef090cb --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/access/access-content.tsx @@ -0,0 +1,460 @@ +import { Link } from "@tanstack/react-router"; +import { PencilIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useLocale, useTranslations } from "use-intl"; + +import type { UserOption } from "@/components/form/fields/input-users"; +import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; + +import { resolveRoleName } from "@/components/role-name"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Spinner } from "@/components/ui/spinner"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { formatAiPoints } from "@/lib/ai/format-points"; + +import type { + AdminAiAccess, + AdminAiRoleAccess, + AdminAiUserOverride, +} from "../ai-query"; +import type { AiRoleAccessFormProps } from "./role-access-form-content"; +import type { AiUserOverrideFormProps } from "./user-override-form-content"; + +const AiRoleAccessFormContent = React.lazy(async () => + import("./role-access-form-content").then(module => ({ + default: module.AiRoleAccessFormContent, + })), +); + +const AiUserOverrideFormContent = React.lazy(async () => + import("./user-override-form-content").then(module => ({ + default: module.AiUserOverrideFormContent, + })), +); + +const DialogFallback = () => ( +
+ +
+); + +export interface AiAccessContentProps { + canManage: boolean; + data: AdminAiAccess; + describePermission: (key: string) => string; + onDeleteUserOverride: (userId: number) => Promise>; + onSaveRole: AiRoleAccessFormProps["onSave"]; + onSaveUserOverride: AiUserOverrideFormProps["onSave"]; + searchUsers: (search: string) => Promise; +} + +/** The four separate controls, so nobody mistakes one for another. */ +const AiAccessLayers = () => { + const t = useTranslations("admin.ai.access.layers"); + + return ( + + + {t("title")} + + +
+ {(["permission", "points", "daily", "budget"] as const).map(key => ( +
+
{t(`${key}.title`)}
+
+ {t(`${key}.desc`)} + {key === "budget" ? ( + <> + {" "} + + {t("budget.link")} + + + ) : null} +
+
+ ))} +
+
+
+ ); +}; + +const AllowanceBadge = ({ + monthlyPoints, + unlimited, +}: { + monthlyPoints: null | string; + unlimited: boolean; +}) => { + const t = useTranslations("admin.ai.access"); + const locale = useLocale(); + + if (unlimited) return {t("unlimited")}; + if (monthlyPoints === null) { + return {t("site_default")}; + } + + return ( + + {t("points_month", { points: formatAiPoints(monthlyPoints, locale) })} + + ); +}; + +const AiRoleCard = ({ + canManage, + describePermission, + onSave, + permissions, + role, +}: { + canManage: boolean; + describePermission: AiAccessContentProps["describePermission"]; + onSave: AiRoleAccessFormProps["onSave"]; + permissions: AdminAiAccess["permissions"]; + role: AdminAiRoleAccess; +}) => { + const t = useTranslations("admin.ai.access.roles"); + const locale = useLocale(); + const name = resolveRoleName(role, locale); + const allowed = permissions.filter(permission => { + const grant = role.grants.find(row => row.permission === permission.key); + + return grant ? grant.granted : permission.defaultGranted; + }).length; + + return ( + + + + {name} + {role.root ? {t("root")} : null} + + + {t("features_allowed", { allowed, total: permissions.length })} + + + + {canManage ? ( + + + + } + > + + + + + + {t("edit_title", { name })} + {t("edit_desc")} + + }> + + + + + ) : null} + + + + ); +}; + +const UserOverrideDialog = ({ + data, + onSave, + searchUsers, + trigger, +}: Omit & { + data?: AdminAiUserOverride; + trigger: React.ReactElement; +}) => { + const t = useTranslations("admin.ai.access.users"); + + return ( + + {trigger} + + + + {data ? t("edit_title", { name: data.user.name }) : t("add_title")} + + {t("form_desc")} + + }> + + + + + ); +}; + +const RemoveUserOverrideAction = ({ + onDelete, + override, +}: { + onDelete: AiAccessContentProps["onDeleteUserOverride"]; + override: AdminAiUserOverride; +}) => { + const t = useTranslations("admin.ai.access.users.remove"); + const tError = useTranslations("core.global.errors"); + const [open, setOpen] = React.useState(false); + const [isPending, startTransition] = React.useTransition(); + + return ( + + + + } + > + + + + + + {t("title")} + + {t("desc", { name: override.user.name })} + + + + {t("cancel")} + + + + + ); +}; + +const AiUserOverridesSection = ({ + canManage, + onDelete, + onSave, + overrides, + searchUsers, +}: { + canManage: boolean; + onDelete: AiAccessContentProps["onDeleteUserOverride"]; + onSave: AiUserOverrideFormProps["onSave"]; + overrides: AdminAiUserOverride[]; + searchUsers: AiAccessContentProps["searchUsers"]; +}) => { + const t = useTranslations("admin.ai.access.users"); + + return ( +
+
+
+

+ {t("title")} +

+

+ {t("desc")} +

+
+ {canManage ? ( + }> + + {t("add")} + + } + /> + ) : null} +
+ + {overrides.length === 0 ? ( +

+ {t("empty")} +

+ ) : ( +
    + {overrides.map(override => ( +
  • +
    + + {override.user.name} + + + @{override.user.nameCode} + +
    +
    + {override.blocked ? ( + {t("blocked")} + ) : ( + + )} + {canManage ? ( + <> + + + } + > + + + + } + /> + + + ) : null} +
    +
  • + ))} +
+ )} +
+ ); +}; + +export const AiAccessContent = ({ + canManage, + data, + describePermission, + onDeleteUserOverride, + onSaveRole, + onSaveUserOverride, + searchUsers, +}: AiAccessContentProps) => { + const t = useTranslations("admin.ai.access.roles"); + + return ( +
+ + +
+
+

+ {t("title")} +

+

+ {data.permissions.length === 0 ? t("no_features") : t("desc")} +

+
+
+ {data.roles.map(role => ( + + ))} +
+
+ + +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/ai/access/role-access-form-content.tsx b/packages/vitnode/src/views/admin/views/core/ai/access/role-access-form-content.tsx new file mode 100644 index 000000000..132e4f66e --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/access/role-access-form-content.tsx @@ -0,0 +1,215 @@ +import { toast } from "sonner"; +import { useLocale, useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { + AutoFormOnSubmit, + ItemAutoFormComponentProps, +} from "@/components/form/auto-form"; +import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; + +import { AI_ACTION_LIMITS } from "@/api/lib/ai/action"; +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormNullableNumber } from "@/components/form/fields/nullable-number"; +import { AutoFormSelect } from "@/components/form/fields/select"; +import { AutoFormSwitch } from "@/components/form/fields/switch"; +import { resolveRoleName } from "@/components/role-name"; +import { useDialog } from "@/components/ui/dialog"; + +import type { AdminAiRoleAccessInput } from "../ai-mutations"; +import type { AdminAiAccess, AdminAiRoleAccess } from "../ai-query"; + +import { fromAiDecimal, toAiDecimal } from "../ai-decimal"; + +const GRANTS = ["default", "allow", "deny"] as const; +type GrantChoice = (typeof GRANTS)[number]; + +const grantId = (index: number) => `grant_${index}`; +const limitId = (index: number) => `limit_${index}`; + +const grantChoiceOf = (granted: boolean | undefined): GrantChoice => + granted === undefined ? "default" : granted ? "allow" : "deny"; + +const isGrantChoice = (value: unknown): value is GrantChoice => + GRANTS.some(choice => choice === value); + +export interface AiRoleAccessFormProps { + describePermission: (key: string) => string; + onSave: (body: AdminAiRoleAccessInput) => Promise>; + permissions: AdminAiAccess["permissions"]; + role: AdminAiRoleAccess; +} + +export const AiRoleAccessFormContent = ({ + describePermission, + onSave, + permissions, + role, +}: AiRoleAccessFormProps) => { + const t = useTranslations("admin.ai.access.role_form"); + const tError = useTranslations("core.global.errors"); + const locale = useLocale(); + const { setIsDirty, setOpen } = useDialog(); + + const grantFields = Object.fromEntries( + permissions.map((permission, index) => { + const grant = role.grants.find(row => row.permission === permission.key); + + return [ + grantId(index), + z.enum(GRANTS).default(grantChoiceOf(grant?.granted)), + ]; + }), + ); + const limitFields = Object.fromEntries( + permissions.map((permission, index) => { + const grant = role.grants.find(row => row.permission === permission.key); + + return [ + limitId(index), + z + .number() + .int() + .min(AI_ACTION_LIMITS.dailyLimit.min) + .max(AI_ACTION_LIMITS.dailyLimit.max) + .nullable() + .default(grant?.dailyLimit ?? null), + ]; + }), + ); + + const formSchema = z.object({ + unlimited: z.boolean().default(role.unlimited), + monthlyPoints: z + .number() + .min(0) + .nullable() + .default(fromAiDecimal(role.monthlyPoints)), + ...grantFields, + ...limitFields, + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const record = values as Record; + const result = await onSave({ + grants: permissions.map((permission, index) => { + const choice = record[grantId(index)]; + const limit = record[limitId(index)]; + const granted = + !isGrantChoice(choice) || choice === "default" + ? null + : choice === "allow"; + + return { + dailyLimit: granted && typeof limit === "number" ? limit : null, + granted, + permission: permission.key, + }; + }), + monthlyPoints: + values.monthlyPoints === null + ? null + : toAiDecimal(values.monthlyPoints), + roleId: role.id, + unlimited: values.unlimited, + }); + + if ("error" in result) { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + + return; + } + + toast.success(t("saved.title"), { + description: t("saved.desc", { name: resolveRoleName(role, locale) }), + }); + setIsDirty?.(false); + setOpen?.(false); + }; + + const grantLabels = (defaultGranted: boolean) => [ + { + label: defaultGranted + ? t("grant.default_allowed") + : t("grant.default_denied"), + value: "default", + }, + { label: t("grant.allow"), value: "allow" }, + { label: t("grant.deny"), value: "deny" }, + ]; + + return ( + ( + + ), + id: "unlimited", + tab: "allowance", + }, + { + component: props => ( + + ), + hidden: values => values.unlimited === true, + id: "monthlyPoints", + tab: "allowance", + }, + ...permissions.flatMap((permission, index) => [ + { + component: (props: ItemAutoFormComponentProps) => ( + + ), + id: grantId(index), + tab: "features", + }, + { + component: (props: ItemAutoFormComponentProps) => ( + + ), + hidden: (values: Record) => + values[grantId(index)] !== "allow", + id: limitId(index), + tab: "features", + }, + ]), + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("submit") }} + tabs={[ + { label: t("tabs.allowance"), value: "allowance" }, + { label: t("tabs.features"), value: "features" }, + ]} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/ai/access/user-override-form-content.tsx b/packages/vitnode/src/views/admin/views/core/ai/access/user-override-form-content.tsx new file mode 100644 index 000000000..6a6eec363 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/access/user-override-form-content.tsx @@ -0,0 +1,140 @@ +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; +import type { UserOption } from "@/components/form/fields/input-users"; +import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormUser } from "@/components/form/fields/input-users"; +import { AutoFormNullableNumber } from "@/components/form/fields/nullable-number"; +import { AutoFormSwitch } from "@/components/form/fields/switch"; +import { useDialog } from "@/components/ui/dialog"; + +import type { AdminAiUserOverrideInput } from "../ai-mutations"; +import type { AdminAiUserOverride } from "../ai-query"; + +import { fromAiDecimal, toAiDecimal } from "../ai-decimal"; + +export interface AiUserOverrideFormProps { + /** The exception being edited. Absent when adding one. */ + data?: AdminAiUserOverride; + onSave: ( + body: AdminAiUserOverrideInput, + ) => Promise>; + searchUsers: (search: string) => Promise; +} + +export const AiUserOverrideFormContent = ({ + data, + onSave, + searchUsers, +}: AiUserOverrideFormProps) => { + const t = useTranslations("admin.ai.access.user_form"); + const tError = useTranslations("core.global.errors"); + const { setIsDirty, setOpen } = useDialog(); + + const formSchema = z.object({ + userId: z + .number({ message: t("user_required") }) + .int() + .positive() + .nullable() + .default(data?.user.id ?? null) + .refine(value => value !== null, { message: t("user_required") }), + blocked: z.boolean().default(data?.blocked ?? false), + unlimited: z.boolean().default(data?.unlimited ?? false), + monthlyPoints: z + .number() + .min(0) + .nullable() + .default(fromAiDecimal(data?.monthlyPoints ?? null)), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + if (values.userId === null) return; + + const result = await onSave({ + blocked: values.blocked, + monthlyPoints: + values.monthlyPoints === null + ? null + : toAiDecimal(values.monthlyPoints), + unlimited: values.unlimited, + userId: values.userId, + }); + + if ("error" in result) { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + + return; + } + + toast.success(t("saved.title"), { description: t("saved.desc") }); + setIsDirty?.(false); + setOpen?.(false); + }; + + return ( + ( + + ), + id: "userId", + }, + { + component: props => ( + + ), + id: "blocked", + }, + { + component: props => ( + + ), + hidden: values => values.blocked === true, + id: "unlimited", + }, + { + component: props => ( + + ), + hidden: values => + values.blocked === true || values.unlimited === true, + id: "monthlyPoints", + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("submit") }} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/ai/actions/action-form-content.tsx b/packages/vitnode/src/views/admin/views/core/ai/actions/action-form-content.tsx new file mode 100644 index 000000000..d6c463390 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/actions/action-form-content.tsx @@ -0,0 +1,239 @@ +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { + AutoFormOnSubmit, + ItemAutoFormComponentProps, +} from "@/components/form/auto-form"; +import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; + +import { AI_ACTION_LIMITS } from "@/api/lib/ai/action"; +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormNullableNumber } from "@/components/form/fields/nullable-number"; +import { AutoFormSelect } from "@/components/form/fields/select"; +import { AutoFormSwitch } from "@/components/form/fields/switch"; +import { AutoFormTextarea } from "@/components/form/fields/textarea"; +import { useDialog } from "@/components/ui/dialog"; + +import type { AdminAiActionInput } from "../ai-mutations"; +import type { AdminAiAction, AdminAiModel } from "../ai-query"; + +/** The select's spelling of `null`: the action's own default model. */ +export const AI_DEFAULT_MODEL = "__default__"; + +type LimitField = Exclude< + keyof typeof AI_ACTION_LIMITS, + "dailyLimit" | "maxImages" +>; + +export interface AiActionFormProps { + action: AdminAiAction; + models: AdminAiModel[]; + onSave: (body: AdminAiActionInput) => Promise>; +} + +export const AiActionFormContent = ({ + action, + models, + onSave, +}: AiActionFormProps) => { + const t = useTranslations("admin.ai.actions.form"); + const tError = useTranslations("core.global.errors"); + const { setIsDirty, setOpen } = useDialog(); + const { defaults, settings } = action; + + const modelIds = [ + ...new Set( + [ + settings.modelId, + settings.fallbackModelId, + ...action.compatibleModelIds, + ].filter((id): id is string => id !== null), + ), + ]; + const modelOptions = [AI_DEFAULT_MODEL, ...modelIds] as [string, ...string[]]; + const modelLabels = [ + { label: t("model_default"), value: AI_DEFAULT_MODEL }, + ...modelIds.map(id => ({ + label: models.find(model => model.id === id)?.name ?? id, + value: id, + })), + ]; + + const limit = (field: "dailyLimit" | LimitField) => + z + .number() + .int() + .min(AI_ACTION_LIMITS[field].min) + .max(AI_ACTION_LIMITS[field].max) + .nullable() + .default(settings[field]); + + const formSchema = z.object({ + enabled: z.boolean().default(settings.enabled), + modelId: z.enum(modelOptions).default(settings.modelId ?? AI_DEFAULT_MODEL), + fallbackModelId: z + .enum(modelOptions) + .default(settings.fallbackModelId ?? AI_DEFAULT_MODEL), + dailyLimit: limit("dailyLimit"), + maxInputCharacters: limit("maxInputCharacters"), + maxOutputTokens: limit("maxOutputTokens"), + maxRetries: limit("maxRetries"), + maxSteps: limit("maxSteps"), + timeoutMs: limit("timeoutMs"), + instructions: z + .string() + .max(2_000) + .default(settings.instructions ?? ""), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const model = (value: string) => + value === AI_DEFAULT_MODEL ? null : value; + const result = await onSave({ + dailyLimit: values.dailyLimit, + enabled: values.enabled, + fallbackModelId: model(values.fallbackModelId), + instructions: values.instructions.trim() || null, + key: action.key, + maxInputCharacters: values.maxInputCharacters, + maxOutputTokens: values.maxOutputTokens, + maxRetries: values.maxRetries, + maxSteps: values.maxSteps, + modelId: model(values.modelId), + timeoutMs: values.timeoutMs, + }); + + if ("error" in result) { + toast.error(tError("title"), { + description: + result.error.status === 400 && result.error.message + ? result.error.message + : tError("internal_server_error"), + }); + + return; + } + + toast.success(t("saved.title"), { + description: t("saved.desc", { key: action.key }), + }); + setIsDirty?.(false); + setOpen?.(false); + }; + + const limitField = + (label: string, fallback: null | number, unit?: string) => + (props: ItemAutoFormComponentProps) => ( + + ); + + return ( + ( + + ), + id: "enabled", + tab: "general", + }, + { + component: props => ( + + ), + id: "modelId", + tab: "general", + }, + { + component: props => ( + + ), + id: "fallbackModelId", + tab: "general", + }, + { + component: props => ( + + ), + id: "instructions", + tab: "general", + }, + { + component: limitField(t("daily_limit"), defaults.dailyLimit), + id: "dailyLimit", + tab: "limits", + }, + { + component: limitField( + t("max_input_characters"), + defaults.maxInputCharacters, + ), + id: "maxInputCharacters", + tab: "limits", + }, + { + component: limitField( + t("max_output_tokens"), + defaults.maxOutputTokens, + ), + id: "maxOutputTokens", + tab: "limits", + }, + { + component: limitField(t("max_retries"), defaults.maxRetries), + id: "maxRetries", + tab: "limits", + }, + { + component: limitField(t("max_steps"), defaults.maxSteps), + id: "maxSteps", + tab: "limits", + }, + { + component: limitField(t("timeout"), defaults.timeoutMs, "ms"), + id: "timeoutMs", + tab: "limits", + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("submit") }} + tabs={[ + { label: t("tabs.general"), value: "general" }, + { label: t("tabs.limits"), value: "limits" }, + ]} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/ai/actions/action-test-content.tsx b/packages/vitnode/src/views/admin/views/core/ai/actions/action-test-content.tsx new file mode 100644 index 000000000..153e70ab8 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/actions/action-test-content.tsx @@ -0,0 +1,139 @@ +import React from "react"; +import { toast } from "sonner"; +import { useLocale, useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; +import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormTextarea } from "@/components/form/fields/textarea"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { formatAiPoints } from "@/lib/ai/format-points"; + +import type { AdminAiTestResult } from "../ai-mutations"; + +const parsesAsJson = (value: string): boolean => { + try { + JSON.parse(value); + + return true; + } catch { + return false; + } +}; + +export interface AiActionTestProps { + actionKey: string; + onTest: (body: { + input: unknown; + key: string; + }) => Promise>; +} + +const AiActionTestResultContent = ({ + result, +}: { + result: AdminAiTestResult; +}) => { + const t = useTranslations("admin.ai.actions.test"); + const locale = useLocale(); + const output = + typeof result.output === "string" + ? result.output + : JSON.stringify(result.output, null, 2); + + return ( +
+
+ {t("result")} + + {t("charged", { + points: formatAiPoints(result.usage.chargedPoints, locale), + })} + + {result.usage.modelId} + {result.usage.costKnown ? null : ( + {t("cost_unknown")} + )} +
+
+        {output}
+      
+

+ {t("run_id", { id: result.runId })} +

+
+ ); +}; + +export const AiActionTestContent = ({ + actionKey, + onTest, +}: AiActionTestProps) => { + const t = useTranslations("admin.ai.actions.test"); + const tError = useTranslations("core.global.errors"); + const [result, setResult] = React.useState(null); + + const formSchema = z.object({ + input: z + .string() + .min(1) + .max(100_000) + .refine(parsesAsJson, { message: t("invalid_json") }) + .default("{}"), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const response = await onTest({ + input: JSON.parse(values.input) as unknown, + key: actionKey, + }); + + if ("error" in response) { + setResult(null); + toast.error(t("failed"), { + description: response.error.message ?? tError("internal_server_error"), + }); + + return; + } + + setResult(response.data); + toast.success(t("done.title"), { + description: t("done.desc", { id: response.data.runId }), + }); + }; + + return ( +
+ + {t("billed.title")} + {t("billed.desc")} + + + {result ? : null} + + ( + + ), + id: "input", + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("submit") }} + /> +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/ai/actions/actions-content.tsx b/packages/vitnode/src/views/admin/views/core/ai/actions/actions-content.tsx new file mode 100644 index 000000000..6d5411fe5 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/actions/actions-content.tsx @@ -0,0 +1,230 @@ +import { FlaskConicalIcon, PencilIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "@/components/ui/empty"; +import { Spinner } from "@/components/ui/spinner"; + +import type { AdminAiAction, AdminAiModel } from "../ai-query"; +import type { AiActionFormProps } from "./action-form-content"; +import type { AiActionTestProps } from "./action-test-content"; + +import { AiActionLabel } from "../ai-labels"; + +const AiActionFormContent = React.lazy(async () => + import("./action-form-content").then(module => ({ + default: module.AiActionFormContent, + })), +); + +const AiActionTestContent = React.lazy(async () => + import("./action-test-content").then(module => ({ + default: module.AiActionTestContent, + })), +); + +const DialogFallback = () => ( +
+ +
+); + +export interface AiActionsContentProps { + actions: AdminAiAction[]; + canManage: boolean; + models: AdminAiModel[]; + onSave: AiActionFormProps["onSave"]; + onTest: AiActionTestProps["onTest"]; +} + +export const EditAiActionAction = ({ + action, + models, + onSave, +}: Pick & { + action: AdminAiAction; +}) => { + const t = useTranslations("admin.ai.actions.form"); + + return ( + + }> + + {t("open")} + + + + {t("title")} + + {action.key} + + + }> + + + + + ); +}; + +const TestAiActionAction = ({ + action, + onTest, +}: Pick & { action: AdminAiAction }) => { + const t = useTranslations("admin.ai.actions.test"); + + return ( + + }> + + {t("open")} + + + + {t("title")} + + {action.key} + + + }> + + + + + ); +}; + +const AiActionFact = ({ + label, + value, +}: { + label: React.ReactNode; + value: React.ReactNode; +}) => ( +
+
{label}
+
{value}
+
+); + +const AiActionCard = ({ + action, + canManage, + models, + onSave, + onTest, +}: Omit & { action: AdminAiAction }) => { + const t = useTranslations("admin.ai.actions"); + const tOrigin = useTranslations("admin.ai.origin"); + const modelName = (id: null | string) => + id === null + ? t("model_default") + : (models.find(model => model.id === id)?.name ?? id); + const dailyLimit = action.settings.dailyLimit ?? action.defaults.dailyLimit; + const canTest = action.actors.includes("user"); + + return ( + + + + + + + + {action.settings.enabled ? t("enabled") : t("disabled")} + + + + +
+ + + tOrigin(actor)).join(", ")} + /> + {t("no_compatible")} + ) : ( + action.compatibleModelIds.length + ) + } + /> +
+
+ {canManage ? ( + + + {canTest ? ( + + ) : null} + + ) : null} +
+ ); +}; + +export const AiActionsContent = ({ + actions, + ...props +}: AiActionsContentProps) => { + const t = useTranslations("admin.ai.actions"); + + if (actions.length === 0) { + return ( + + + {t("empty.title")} + {t("empty.desc")} + + + ); + } + + return ( +
+ {actions.map(action => ( + + ))} +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/ai/ai-decimal.test.ts b/packages/vitnode/src/views/admin/views/core/ai/ai-decimal.test.ts new file mode 100644 index 000000000..51fde1a8c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/ai-decimal.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { fromAiDecimal, toAiDecimal } from "./ai-decimal"; + +describe("toAiDecimal", () => { + it("writes a plain decimal without trailing zeros", () => { + expect(toAiDecimal(25)).toBe("25"); + expect(toAiDecimal(25.5)).toBe("25.5"); + expect(toAiDecimal(0.000_000_1, 8)).toBe("0.0000001"); + }); + + it("never writes exponent notation or a negative amount", () => { + expect(toAiDecimal(1e-7)).toBe("0"); + expect(toAiDecimal(-3)).toBe("0"); + }); + + it("reads the API's string back, keeping null as null", () => { + expect(fromAiDecimal("12.50")).toBe(12.5); + expect(fromAiDecimal(null)).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/ai/ai-decimal.ts b/packages/vitnode/src/views/admin/views/core/ai/ai-decimal.ts new file mode 100644 index 000000000..2f30d34db --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/ai-decimal.ts @@ -0,0 +1,13 @@ +/** + * A form's number as the decimal string the AI API takes. Money never travels + * as a float, and `String(1e-7)` is not a decimal the API would accept. + */ +export const toAiDecimal = (value: number, fractionDigits = 6): string => { + const fixed = Math.max(value, 0).toFixed(fractionDigits); + + return fixed.includes(".") ? fixed.replace(/\.?0+$/, "") : fixed; +}; + +/** The API's decimal string as a form's number. */ +export const fromAiDecimal = (value: null | string): null | number => + value === null ? null : Number(value); diff --git a/packages/vitnode/src/views/admin/views/core/ai/ai-labels.tsx b/packages/vitnode/src/views/admin/views/core/ai/ai-labels.tsx new file mode 100644 index 000000000..bcedeb742 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/ai-labels.tsx @@ -0,0 +1,110 @@ +import { cn } from "cn"; +import { useLocale, useTranslations } from "use-intl"; + +import { Badge } from "@/components/ui/badge"; +import { formatAiUsd } from "@/lib/ai/format-points"; +import { aiRunStatusVariant, isAiRunStatus } from "@/lib/ai/run-status"; + +/** An action as people read it: what it does, then its key for the record. */ +export const AiActionLabel = ({ + actionKey, + className, + description, +}: { + actionKey: string; + className?: string; + description?: null | string; +}) => ( +
+ {description ? ( + + {description} + + ) : null} + + {actionKey} + +
+); + +/** + * A USD amount, or "Unknown" - an unknown cost must never read as `$0.00`. + */ +export const AiUsd = ({ + className, + value, +}: { + className?: string; + value: null | string | undefined; +}) => { + const t = useTranslations("admin.ai.cost"); + const locale = useLocale(); + + if (value === null || value === undefined) { + return ( + + {t("unknown")} + + ); + } + + return ( + + {formatAiUsd(value, locale)} + + ); +}; + +const COST_SOURCE_KEYS = { + manual: "estimated", + mixed: "mixed", + pricing: "estimated", + provider: "provider", + unknown: "unknown", +} as const; + +/** Where a cost came from: the provider's own bill, our price table, or nowhere. */ +export const AiCostSourceBadge = ({ source }: { source: null | string }) => { + const t = useTranslations("admin.ai.cost.source"); + const key = + source && source in COST_SOURCE_KEYS + ? COST_SOURCE_KEYS[source as keyof typeof COST_SOURCE_KEYS] + : "unknown"; + + return ( + + {t(key)} + + ); +}; + +export const AiRunStatusBadge = ({ status }: { status: string }) => { + const t = useTranslations("admin.ai.status"); + + return ( + + {isAiRunStatus(status) ? t(status) : status} + + ); +}; + +/** `0.983` as `98%` in the reader's locale. */ +export const formatAiShare = (share: number, locale: string): string => + new Intl.NumberFormat(locale, { + maximumFractionDigits: share > 0 && share < 0.01 ? 1 : 0, + style: "percent", + }).format(share); diff --git a/packages/vitnode/src/views/admin/views/core/ai/ai-mutations.ts b/packages/vitnode/src/views/admin/views/core/ai/ai-mutations.ts new file mode 100644 index 000000000..2ac80b564 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/ai-mutations.ts @@ -0,0 +1,206 @@ +import type { z } from "zod"; + +import type { + zodAiActionSettingsInput, + zodAiSettings, +} from "@/api/modules/admin/ai/schemas"; + +import { CONFIG_PLUGIN } from "@/config"; +import { fetcherClient } from "@/lib/fetcher-client"; +import { + type AdminMutationResult, + runAdminApiMutation, +} from "@/views/admin/views/core/shared/admin-mutation"; + +import type { AdminAiPricing } from "./ai-query"; + +export type AdminAiSettingsInput = z.infer; +export type AdminAiActionInput = z.infer; + +export interface AdminAiRoleAccessInput { + grants: { + dailyLimit: null | number; + /** `null` removes the row, so the action's default applies. */ + granted: boolean | null; + permission: string; + }[]; + monthlyPoints: null | string; + roleId: number; + unlimited: boolean; +} + +export interface AdminAiUserOverrideInput { + blocked: boolean; + monthlyPoints: null | string; + unlimited: boolean; + userId: number; +} + +export interface AdminAiSyncPricingResult { + unchanged: string[]; + unpriced: string[]; + updated: string[]; +} + +export interface AdminAiTestResult { + output: unknown; + runId: number; + usage: { chargedPoints: string; costKnown: boolean; modelId: string }; +} + +const ok = () => true as const; + +export const updateAdminAiSettings = async ( + body: AdminAiSettingsInput, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body }, + method: "put", + module: "admin/ai", + options: { credentials: "include" }, + path: "/settings", + }), + }); + +export const updateAdminAiPricing = async (body: { + modelId: string; + pricing: AdminAiPricing; +}): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body }, + method: "put", + module: "admin/ai", + options: { credentials: "include" }, + path: "/models/pricing", + }), + }); + +export const deleteAdminAiPricing = async ( + modelId: string, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: { modelId } }, + method: "delete", + module: "admin/ai", + options: { credentials: "include" }, + path: "/models/pricing", + }), + }); + +export const syncAdminAiPricing = async (): Promise< + AdminMutationResult +> => + await runAdminApiMutation({ + expected: 200, + parse: async response => + (await response.json()) as AdminAiSyncPricingResult, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + method: "post", + module: "admin/ai", + options: { credentials: "include" }, + path: "/models/sync-pricing", + }), + }); + +export const updateAdminAiAction = async ( + body: AdminAiActionInput, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body }, + method: "put", + module: "admin/ai", + options: { credentials: "include" }, + path: "/actions", + }), + }); + +export const testAdminAiAction = async (body: { + input: unknown; + key: string; +}): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: async response => (await response.json()) as AdminAiTestResult, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body }, + method: "post", + module: "admin/ai", + options: { credentials: "include" }, + path: "/actions/test", + }), + }); + +export const updateAdminAiRoleAccess = async ( + body: AdminAiRoleAccessInput, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body }, + method: "put", + module: "admin/ai", + options: { credentials: "include" }, + path: "/access/roles", + }), + }); + +export const updateAdminAiUserOverride = async ( + body: AdminAiUserOverrideInput, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body }, + method: "put", + module: "admin/ai", + options: { credentials: "include" }, + path: "/access/users", + }), + }); + +export const deleteAdminAiUserOverride = async ( + userId: number, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: { userId } }, + method: "delete", + module: "admin/ai", + options: { credentials: "include" }, + path: "/access/users", + }), + }); diff --git a/packages/vitnode/src/views/admin/views/core/ai/ai-permissions.ts b/packages/vitnode/src/views/admin/views/core/ai/ai-permissions.ts new file mode 100644 index 000000000..71d93ff1a --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/ai-permissions.ts @@ -0,0 +1,15 @@ +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; + +import { CONFIG_PLUGIN } from "@/config"; + +const ai = (permission: string): PermissionsStaffArgs => ({ + module: "ai", + permission, + plugin: CONFIG_PLUGIN.pluginId, +}); + +/** The AI screens' staff permissions - `ai` on `@vitnode/core`. */ +export const ADMIN_AI_PERMISSIONS = { + manage: ai("can_manage"), + view: ai("can_view"), +} as const; diff --git a/packages/vitnode/src/views/admin/views/core/ai/ai-query.ts b/packages/vitnode/src/views/admin/views/core/ai/ai-query.ts new file mode 100644 index 000000000..f161ef372 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/ai-query.ts @@ -0,0 +1,433 @@ +import type { z } from "zod"; + +import { queryOptions } from "@tanstack/react-query"; + +import type { zodAiPricing } from "@/api/lib/ai/pricing"; +import type { + zodAiAction, + zodAiModel, + zodAiSettingsResponse, +} from "@/api/modules/admin/ai/schemas"; +import type { AiRunStatusValue } from "@/lib/ai/run-status"; +import type { + AdminTableContract, + AdminTablePage, + AdminTableParams, +} from "@/views/admin/table/params"; +import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; + +import { CONFIG_PLUGIN } from "@/config"; +import { + OPERATIONAL_STALE_TIME, + RECORD_STALE_TIME, +} from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; +import { + AdminRequestError, + describeAdminParams, +} from "@/views/admin/admin-request"; +import { + adminScopedQueryKey, + adminScopedQueryRoot, +} from "@/views/admin/views/core/shared/admin-scope"; + +export const ADMIN_AI_SCREEN = "ai"; + +export const AI_OVERVIEW_PERIODS = ["current", "previous"] as const; +export type AiOverviewPeriod = (typeof AI_OVERVIEW_PERIODS)[number]; + +export interface AiBreakdownRow { + failures: number; + key: string; + knownCostUsd: string; + knownOperations: number; + operations: number; +} + +/** `GET /admin/ai/overview`. */ +export interface AdminAiOverview { + alt: { + imagesDescribed: number; + knownCostUsd: string; + perImageUsd: null | string; + perTranslationUsd: null | string; + translations: number; + }; + averageDailyCostUsd: string; + averageOperationCostUsd: null | string; + budget: { + limitUsd: null | string; + remainingUsd: null | string; + reservedUsd: string; + spentUsd: string; + systemLimitUsd: null | string; + systemSpentUsd: string; + }; + byAction: AiBreakdownRow[]; + byModel: AiBreakdownRow[]; + byOrigin: AiBreakdownRow[]; + costSources: { estimated: number; provider: number; unknown: number }; + enabled: boolean; + failureRate: number; + knownCostUsd: string; + operations: number; + period: { end: string; start: string }; + pricingCoverage: number; + tokens: { input: number; output: number }; +} + +export type AdminAiSettings = z.infer; +export type AdminAiModel = z.infer; +export type AdminAiPricing = z.infer; +export type AdminAiAction = z.infer; + +export interface AdminAiAccess { + overrides: { + blocked: boolean; + monthlyPoints: null | string; + unlimited: boolean; + user: { id: number; name: string; nameCode: string }; + }[]; + permissions: { actions: string[]; defaultGranted: boolean; key: string }[]; + roles: AdminAiRoleAccess[]; +} + +export interface AdminAiRoleAccess { + grants: { dailyLimit: null | number; granted: boolean; permission: string }[]; + id: number; + monthlyPoints: null | string; + name: { languageCode: string; name: string }[]; + root: boolean; + unlimited: boolean; +} + +export type AdminAiUserOverride = AdminAiAccess["overrides"][number]; + +/** One row of the AI history, as JSON delivers it. */ +export interface AdminAiRunRow { + accepted: boolean | null; + actionKey: string; + actorType: "system" | "user"; + chargedPoints: null | string; + chargedUsd: null | string; + costSource: null | string; + costUsd: null | string; + createdAt: Date | string; + errorCode: null | string; + finishedAt: Date | null | string; + id: number; + inputTokens: null | number; + modelId: null | string; + outputTokens: null | number; + provider: null | string; + resourceId: null | string; + resourceType: null | string; + status: AiRunStatusValue; + user: null | { id: number; name: string; nameCode: string }; +} + +export interface AdminAiRunCall { + attempt: number; + cacheReadTokens: null | number; + cacheWriteTokens: null | number; + costReason: null | string; + costSource: string; + costUsd: null | string; + errorCode: null | string; + finishedAt: Date | null | string; + id: number; + images: number; + inputTokens: null | number; + modelId: string; + outputTokens: null | number; + pricingVersion: null | string; + provider: string; + providerModelId: null | string; + providerRequestId: null | string; + reasoningTokens: null | number; + startedAt: Date | string; + status: string; +} + +export interface AdminAiRunAdjustment { + createdAt: Date | string; + deltaUsd: string; + id: number; + newCostUsd: string; + newSource: string; + previousCostUsd: null | string; + previousSource: string; + reason: string; +} + +/** `GET /admin/ai/history/{id}`. */ +export interface AdminAiRunDetail { + adjustments: AdminAiRunAdjustment[]; + calls: AdminAiRunCall[]; + run: AdminAiRunRow & { + promptVersion: number; + reservedPoints: string; + reservedUsd: string; + settlement: string; + }; +} + +export const AI_HISTORY_ORDER_BY = ["createdAt"] as const; +export type AiHistoryOrderBy = (typeof AI_HISTORY_ORDER_BY)[number]; + +export const AI_HISTORY_TABLE_CONTRACT: AdminTableContract = { + orderBy: AI_HISTORY_ORDER_BY, +}; + +export const AI_HISTORY_ACTOR_TYPES = ["user", "system"] as const; +export type AiHistoryActorType = (typeof AI_HISTORY_ACTOR_TYPES)[number]; + +/** The filters the history route reads, one value each. */ +export interface AiHistoryFilters { + action?: string; + actorType?: AiHistoryActorType; + modelId?: string; + status?: AiRunStatusValue; +} + +export type AiHistoryParams = AiHistoryFilters & + Omit, "orderBy" | "search" | "status">; + +export type AdminAiHistoryPage = AdminTablePage; + +export type AdminAiOverviewFetcher = ( + period: AiOverviewPeriod, +) => Promise; + +export const fetchAdminAiOverview: AdminAiOverviewFetcher = async period => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: { period } }, + method: "get", + module: "admin/ai", + path: "/overview", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, "the AI overview", period); + } + + return await response.json(); +}; + +export type AdminAiSettingsFetcher = () => Promise; + +export const fetchAdminAiSettings: AdminAiSettingsFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/ai", + path: "/settings", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, "the AI settings"); + } + + return await response.json(); +}; + +export type AdminAiModelsFetcher = () => Promise<{ models: AdminAiModel[] }>; + +export const fetchAdminAiModels: AdminAiModelsFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/ai", + path: "/models", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, "the AI models"); + } + + return await response.json(); +}; + +export type AdminAiActionsFetcher = () => Promise<{ + actions: AdminAiAction[]; +}>; + +export const fetchAdminAiActions: AdminAiActionsFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/ai", + path: "/actions", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, "the AI actions"); + } + + return await response.json(); +}; + +export type AdminAiAccessFetcher = () => Promise; + +export const fetchAdminAiAccess: AdminAiAccessFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/ai", + path: "/access", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, "the AI access"); + } + + return await response.json(); +}; + +export type AdminAiHistoryFetcher = ( + params: AiHistoryParams, +) => Promise; + +export const fetchAdminAiHistory: AdminAiHistoryFetcher = async params => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: params }, + method: "get", + module: "admin/ai", + path: "/history", + }); + + if (!response.ok) { + throw new AdminRequestError( + response.status, + "the AI history", + describeAdminParams(params), + ); + } + + return await response.json(); +}; + +export type AdminAiRunFetcher = (id: number) => Promise; + +export const fetchAdminAiRun: AdminAiRunFetcher = async id => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { id } }, + method: "get", + module: "admin/ai", + path: "/history/{id}", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, "an AI run", `id=${id}`); + } + + return await response.json(); +}; + +/** Every cached read of the AI screens for one admin. */ +export const adminAiQueryRoot = (adminUserId: AdminIdentity) => + adminScopedQueryRoot(ADMIN_AI_SCREEN, adminUserId); + +export const adminAiOverviewQueryOptions = ({ + adminUserId, + period, +}: { + adminUserId: AdminIdentity; + period: AiOverviewPeriod; +}) => + queryOptions({ + queryFn: async () => await fetchAdminAiOverview(period), + queryKey: adminScopedQueryKey( + ADMIN_AI_SCREEN, + adminUserId, + "overview", + period, + ), + retry: false, + /** {@link OPERATIONAL_STALE_TIME} - Spending moves with every run. */ + staleTime: OPERATIONAL_STALE_TIME, + }); + +export const adminAiSettingsQueryOptions = ({ + adminUserId, +}: { + adminUserId: AdminIdentity; +}) => + queryOptions({ + queryFn: async () => await fetchAdminAiSettings(), + queryKey: adminScopedQueryKey(ADMIN_AI_SCREEN, adminUserId, "settings"), + retry: false, + staleTime: RECORD_STALE_TIME, + }); + +export const adminAiModelsQueryOptions = ({ + adminUserId, +}: { + adminUserId: AdminIdentity; +}) => + queryOptions({ + queryFn: async () => await fetchAdminAiModels(), + queryKey: adminScopedQueryKey(ADMIN_AI_SCREEN, adminUserId, "models"), + retry: false, + staleTime: RECORD_STALE_TIME, + }); + +export const adminAiActionsQueryOptions = ({ + adminUserId, +}: { + adminUserId: AdminIdentity; +}) => + queryOptions({ + queryFn: async () => await fetchAdminAiActions(), + queryKey: adminScopedQueryKey(ADMIN_AI_SCREEN, adminUserId, "actions"), + retry: false, + staleTime: RECORD_STALE_TIME, + }); + +export const adminAiAccessQueryOptions = ({ + adminUserId, +}: { + adminUserId: AdminIdentity; +}) => + queryOptions({ + queryFn: async () => await fetchAdminAiAccess(), + queryKey: adminScopedQueryKey(ADMIN_AI_SCREEN, adminUserId, "access"), + retry: false, + staleTime: RECORD_STALE_TIME, + }); + +export const adminAiHistoryQueryOptions = ({ + adminUserId, + params, +}: { + adminUserId: AdminIdentity; + params: AiHistoryParams; +}) => + queryOptions({ + queryFn: async () => await fetchAdminAiHistory(params), + queryKey: adminScopedQueryKey( + ADMIN_AI_SCREEN, + adminUserId, + "history", + params, + ), + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); + +export const adminAiRunQueryOptions = ({ + adminUserId, + id, +}: { + adminUserId: AdminIdentity; + id: number; +}) => + queryOptions({ + queryFn: async () => await fetchAdminAiRun(id), + queryKey: adminScopedQueryKey(ADMIN_AI_SCREEN, adminUserId, "run", id), + retry: false, + staleTime: RECORD_STALE_TIME, + }); diff --git a/packages/vitnode/src/views/admin/views/core/ai/history/history-table-content.tsx b/packages/vitnode/src/views/admin/views/core/ai/history/history-table-content.tsx new file mode 100644 index 000000000..755903631 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/history/history-table-content.tsx @@ -0,0 +1,332 @@ +import { HistoryIcon } from "lucide-react"; +import React from "react"; +import { useLocale, useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { ContentDataTable } from "@/components/table/content"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { + NativeSelect, + NativeSelectOption, +} from "@/components/ui/native-select"; +import { Spinner } from "@/components/ui/spinner"; +import { formatAiPoints, formatAiUsd } from "@/lib/ai/format-points"; +import { AI_RUN_STATUS_LIST } from "@/lib/ai/run-status"; + +import type { + AdminAiAction, + AdminAiHistoryPage, + AdminAiModel, + AdminAiRunRow, + AiHistoryFilters, +} from "../ai-query"; +import type { AiRunDetailProps } from "./run-detail-content"; + +import { + AiActionLabel, + AiCostSourceBadge, + AiRunStatusBadge, + AiUsd, +} from "../ai-labels"; +import { AI_HISTORY_ACTOR_TYPES } from "../ai-query"; + +const AiRunDetailContent = React.lazy(async () => + import("./run-detail-content").then(module => ({ + default: module.AiRunDetailContent, + })), +); + +export interface AiHistoryTableProps { + actions: AdminAiAction[]; + data: AdminAiHistoryPage; + filters: AiHistoryFilters; + models: AdminAiModel[]; + onFilterChange: (filters: AiHistoryFilters) => void; + runQuery: AiRunDetailProps["runQuery"]; +} + +const FilterSelect = ({ + id, + label, + onChange, + options, + value, +}: { + id: string; + label: string; + onChange: (value: string | undefined) => void; + options: { label: string; value: string }[]; + value: string | undefined; +}) => { + const t = useTranslations("admin.ai.history.filters"); + + return ( +
+ + { + onChange(event.target.value || undefined); + }} + size="sm" + value={value ?? ""} + > + {t("all")} + {options.map(option => ( + + {option.label} + + ))} + +
+ ); +}; + +const AiHistoryFiltersContent = ({ + actions, + filters, + models, + onFilterChange, +}: Pick< + AiHistoryTableProps, + "actions" | "filters" | "models" | "onFilterChange" +>) => { + const t = useTranslations("admin.ai.history.filters"); + const tStatus = useTranslations("admin.ai.status"); + const tOrigin = useTranslations("admin.ai.origin"); + const set = + (key: TKey) => + (value: string | undefined) => { + onFilterChange({ ...filters, [key]: value }); + }; + + return ( +
+ ({ + label: tStatus(status), + value: status, + }))} + value={filters.status} + /> + ({ + label: tOrigin(actor), + value: actor, + }))} + value={filters.actorType} + /> + ({ + label: action.description ?? action.key, + value: action.key, + }))} + value={filters.action} + /> + ({ label: model.name, value: model.id }))} + value={filters.modelId} + /> +
+ ); +}; + +const AiRunCost = ({ row }: { row: AdminAiRunRow }) => { + const t = useTranslations("admin.ai.history"); + const locale = useLocale(); + + return ( +
+ + {row.costUsd === null && row.chargedUsd !== null ? ( + + {t("charged_reservation", { + amount: formatAiUsd(row.chargedUsd, locale), + })} + + ) : null} + {row.costSource ? : null} +
+ ); +}; + +export const AiHistoryTableContent = ({ + actions, + data, + filters, + models, + onFilterChange, + runQuery, +}: AiHistoryTableProps) => { + const t = useTranslations("admin.ai.history"); + const tOrigin = useTranslations("admin.ai.origin"); + const locale = useLocale(); + const [openRun, setOpenRun] = React.useState(null); + const descriptions = React.useMemo( + () => new Map(actions.map(action => [action.key, action.description])), + [actions], + ); + const modelNames = React.useMemo( + () => new Map(models.map(model => [model.id, model.name])), + [models], + ); + + return ( + <> + + columns={[ + { + accessorKey: "actionKey", + cell: ({ row }) => ( + + ), + header: t("list.action"), + }, + { + accessorKey: "status", + cell: ({ row }) => , + header: t("list.status"), + }, + { + accessorKey: "actorType", + cell: ({ row }) => + row.user ? ( + {row.user.name} + ) : ( + + {tOrigin(row.actorType)} + + ), + header: t("list.actor"), + }, + { + accessorKey: "modelId", + cell: ({ row }) => + row.modelId ? ( + (modelNames.get(row.modelId) ?? ( + {row.modelId} + )) + ) : ( + — + ), + header: t("list.model"), + }, + { + accessorKey: "costUsd", + align: "right", + cell: ({ row }) => , + header: t("list.cost"), + }, + { + accessorKey: "chargedPoints", + align: "right", + cell: ({ row }) => + row.chargedPoints === null ? ( + — + ) : ( + + {formatAiPoints(row.chargedPoints, locale)} + + ), + header: t("list.points"), + }, + { + accessorKey: "createdAt", + cell: ({ row }) => , + header: t("list.created"), + }, + { + align: "right", + cell: ({ row }) => ( + + ), + header: {t("list.details")}, + id: "details", + }, + ]} + customNoResults={{ + description: t("empty.desc"), + icon: , + title: t("empty.title"), + }} + edges={data.edges} + header={ + + } + id="ai-history-table" + order={{ + columns: ["createdAt"], + defaultOrder: { column: "createdAt", order: "desc" }, + }} + pageInfo={data.pageInfo} + /> + + { + if (!open) setOpenRun(null); + }} + open={openRun !== null} + > + + + + {t("detail.title", { id: openRun?.id ?? 0 })} + + + {openRun?.actionKey} + + + {openRun ? ( + + +
+ } + > + + + ) : null} + + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/ai/history/run-detail-content.tsx b/packages/vitnode/src/views/admin/views/core/ai/history/run-detail-content.tsx new file mode 100644 index 000000000..6ae951535 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/history/run-detail-content.tsx @@ -0,0 +1,257 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useFormatter, useLocale, useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { formatAiPoints, formatAiUsd } from "@/lib/ai/format-points"; + +import type { AdminAiRunDetail, adminAiRunQueryOptions } from "../ai-query"; + +import { AiCostSourceBadge, AiRunStatusBadge, AiUsd } from "../ai-labels"; + +export interface AiRunDetailProps { + id: number; + runQuery: (id: number) => ReturnType; +} + +const Fact = ({ + label, + value, +}: { + label: React.ReactNode; + value: React.ReactNode; +}) => ( +
+
{label}
+
{value}
+
+); + +const tokens = (value: null | number) => value ?? "—"; + +export const AiRunSummaryContent = ({ + run, +}: { + run: AdminAiRunDetail["run"]; +}) => { + const t = useTranslations("admin.ai.history.detail"); + const tOrigin = useTranslations("admin.ai.origin"); + const locale = useLocale(); + + return ( +
+ } + /> + + + } /> + } + /> + + + + + } + /> + + ) : ( + "—" + ) + } + /> + + {run.resourceType ? ( + + {run.resourceType}:{run.resourceId ?? "—"} + + } + /> + ) : null} + {run.errorCode ? ( + + {run.errorCode} + + } + /> + ) : null} + {run.accepted === null ? null : ( + + {run.accepted ? t("accepted_yes") : t("accepted_no")} + + } + /> + )} +
+ ); +}; + +const AiRunCallsContent = ({ calls }: { calls: AdminAiRunDetail["calls"] }) => { + const t = useTranslations("admin.ai.history.detail.calls"); + + return ( +
+

+ {t("title")} +

+ {calls.length === 0 ? ( +

{t("empty")}

+ ) : ( + + + + {t("attempt")} + {t("model")} + {t("status")} + {t("tokens")} + {t("cost")} + + + + {calls.map(call => ( + + {call.attempt} + +
+ {call.modelId} + + {call.provider} + {call.providerModelId ? `/${call.providerModelId}` : ""} + +
+
+ +
+ + {call.errorCode ? ( + + {call.errorCode} + + ) : null} +
+
+ + {tokens(call.inputTokens)} / {tokens(call.outputTokens)} + + +
+ + + {call.costReason ? ( + + {call.costReason} + + ) : null} +
+
+
+ ))} +
+
+ )} +
+ ); +}; + +const AiRunAdjustmentsContent = ({ + adjustments, +}: { + adjustments: AdminAiRunDetail["adjustments"]; +}) => { + const t = useTranslations("admin.ai.history.detail.adjustments"); + const locale = useLocale(); + const format = useFormatter(); + + if (adjustments.length === 0) return null; + + return ( +
+

+ {t("title")} +

+
    + {adjustments.map(adjustment => ( +
  • + + {t("change", { + from: formatAiUsd(adjustment.previousCostUsd, locale), + to: formatAiUsd(adjustment.newCostUsd, locale), + })} + + + {adjustment.reason} ·{" "} + {format.dateTime(new Date(adjustment.createdAt), { + dateStyle: "medium", + timeStyle: "short", + })} + +
  • + ))} +
+
+ ); +}; + +export const AiRunDetailContent = ({ id, runQuery }: AiRunDetailProps) => { + const t = useTranslations("admin.ai.history.detail"); + const { data } = useSuspenseQuery(runQuery(id)); + + return ( +
+ + + +

+ {t("privacy")} +

+
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/ai/models/models-content.test.tsx b/packages/vitnode/src/views/admin/views/core/ai/models/models-content.test.tsx new file mode 100644 index 000000000..920f083e2 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/models/models-content.test.tsx @@ -0,0 +1,117 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { describe, expect, it, vi } from "vitest"; + +import type { AdminAiModel } from "../ai-query"; + +import { AiModelsContent } from "./models-content"; + +const model = (overrides: Partial = {}): AdminAiModel => ({ + capabilities: ["text", "structured-output"], + catalogPricing: null, + id: "fast", + model: "gpt-fast", + name: "Fast model", + pricing: { + pricing: { rates: { inputPerMillion: "0.15", outputPerMillion: "0.6" } }, + source: "config", + updatedAt: null, + version: "config:abc", + }, + provider: "openai", + ...overrides, +}); + +const renderModels = (models: AdminAiModel[], { canManage = true } = {}) => { + const props = { + canManage, + models, + onDeletePricing: vi.fn(async () => + Promise.resolve({ data: true as const }), + ), + onSavePricing: vi.fn(async () => Promise.resolve({ data: true as const })), + }; + + render( + + + , + ); + + return props; +}; + +describe("AiModelsContent", () => { + it("marks a model without a price instead of pricing it at zero", () => { + renderModels([model({ pricing: null })]); + + expect(screen.getByText("admin.ai.models.source.none")).toBeTruthy(); + expect(screen.getByText("admin.ai.models.no_price")).toBeTruthy(); + expect(screen.queryByText("$0.00")).toBeNull(); + }); + + it("shows capabilities and where the price came from", () => { + renderModels([model()]); + + expect(screen.getByText("admin.ai.models.capability.text")).toBeTruthy(); + expect( + screen.getByText("admin.ai.models.capability.structured-output"), + ).toBeTruthy(); + expect(screen.getByText("admin.ai.models.source.config")).toBeTruthy(); + }); + + it("removes a manual price after the admin confirms", async () => { + const props = renderModels([ + model({ + catalogPricing: { + rates: { inputPerMillion: "0.15", outputPerMillion: "0.6" }, + }, + pricing: { + pricing: { + rates: { inputPerMillion: "0.1", outputPerMillion: "0.4" }, + }, + source: "manual", + updatedAt: "2026-10-01T10:00:00.000Z", + version: "manual:3", + }, + }), + ]); + + fireEvent.click( + screen.getByRole("button", { name: "admin.ai.models.reset.open" }), + ); + + const confirm = await screen.findByRole("button", { + name: "admin.ai.models.reset.confirm", + }); + expect(screen.getByText("admin.ai.models.reset.desc")).toBeTruthy(); + + fireEvent.click(confirm); + + await waitFor(() => { + expect(props.onDeletePricing).toHaveBeenCalledWith("fast"); + }); + await waitFor(() => { + expect( + screen.queryByRole("button", { name: "admin.ai.models.reset.confirm" }), + ).toBeNull(); + }); + }); + + it("offers no reset for a catalog price, and nothing to an admin who may only view", () => { + renderModels([model()]); + + expect( + screen.queryByRole("button", { name: "admin.ai.models.reset.open" }), + ).toBeNull(); + expect( + screen.getByRole("button", { name: "admin.ai.models.pricing_form.open" }), + ).toBeTruthy(); + }); + + it("hides every write from an admin without can_manage", () => { + renderModels([model({ pricing: null })], { canManage: false }); + + expect(screen.queryByRole("button")).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/ai/models/models-content.tsx b/packages/vitnode/src/views/admin/views/core/ai/models/models-content.tsx new file mode 100644 index 000000000..bea99b23c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/models/models-content.tsx @@ -0,0 +1,342 @@ +import { PencilIcon, RefreshCwIcon, RotateCcwIcon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useLocale, useTranslations } from "use-intl"; + +import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; + +import { DateFormat } from "@/components/date-format"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "@/components/ui/empty"; +import { Spinner } from "@/components/ui/spinner"; +import { formatAiUsd } from "@/lib/ai/format-points"; + +import type { AdminAiSyncPricingResult } from "../ai-mutations"; +import type { AdminAiModel, AdminAiPricing } from "../ai-query"; +import type { AiPricingFormProps } from "./pricing-form-content"; + +const AiPricingFormContent = React.lazy(async () => + import("./pricing-form-content").then(module => ({ + default: module.AiPricingFormContent, + })), +); + +export interface AiModelsContentProps { + canManage: boolean; + models: AdminAiModel[]; + onDeletePricing: (modelId: string) => Promise>; + onSavePricing: AiPricingFormProps["onSave"]; +} + +const AiPriceLines = ({ pricing }: { pricing: AdminAiPricing }) => { + const t = useTranslations("admin.ai.models.price"); + const locale = useLocale(); + const { rates } = pricing; + const usd = (value: string) => formatAiUsd(value, locale); + + return ( +
+
+
{t("input")}
+
{usd(rates.inputPerMillion)}
+
+
+
{t("output")}
+
{usd(rates.outputPerMillion)}
+
+ {rates.cacheReadPerMillion === undefined ? null : ( +
+
{t("cache_read")}
+
{usd(rates.cacheReadPerMillion)}
+
+ )} + {rates.cacheWritePerMillion === undefined ? null : ( +
+
{t("cache_write")}
+
{usd(rates.cacheWritePerMillion)}
+
+ )} + {rates.perRequest === undefined ? null : ( +
+
{t("per_request")}
+
{usd(rates.perRequest)}
+
+ )} + {rates.perImage === undefined ? null : ( +
+
{t("per_image")}
+
{usd(rates.perImage)}
+
+ )} + {pricing.tiers?.length ? ( +

+ {t("tiers", { count: pricing.tiers.length })} +

+ ) : null} +
+ ); +}; + +const EditPricingAction = ({ + model, + onSave, +}: { + model: AdminAiModel; + onSave: AiPricingFormProps["onSave"]; +}) => { + const t = useTranslations("admin.ai.models.pricing_form"); + + return ( + + }> + + {t("open")} + + + + {t("title", { name: model.name })} + {t("desc")} + + + + + } + > + + + + + ); +}; + +const ResetPricingAction = ({ + model, + onDelete, +}: { + model: AdminAiModel; + onDelete: AiModelsContentProps["onDeletePricing"]; +}) => { + const t = useTranslations("admin.ai.models.reset"); + const tError = useTranslations("core.global.errors"); + const [open, setOpen] = React.useState(false); + const [isPending, startTransition] = React.useTransition(); + + return ( + + }> + + {t("open")} + + + + {t("title")} + + {t("desc", { name: model.name })} + + + + {t("cancel")} + + + + + ); +}; + +const AiModelCard = ({ + canManage, + model, + onDeletePricing, + onSavePricing, +}: Omit & { model: AdminAiModel }) => { + const t = useTranslations("admin.ai.models"); + + return ( + + + {model.name} + + {model.provider}/{model.model} + + + {model.pricing ? ( + + {t(`source.${model.pricing.source}`)} + + ) : ( + {t("source.none")} + )} + + + +
    + {model.capabilities.map(capability => ( +
  • + {t(`capability.${capability}`)} +
  • + ))} +
+ + {model.pricing ? ( +
+ + {t("per_million_hint")} + + +
+ ) : ( +

+ {t("no_price")} +

+ )} + + {model.catalogPricing ? ( +

+ {t("catalog_hint")} +

+ ) : null} + + {model.pricing?.updatedAt ? ( +

+ {t("updated")} +

+ ) : null} +
+ {canManage ? ( + + + {model.pricing?.source === "manual" ? ( + + ) : null} + + ) : null} +
+ ); +}; + +export const AiSyncPricingButton = ({ + onSync, +}: { + onSync: () => Promise>; +}) => { + const t = useTranslations("admin.ai.models.sync"); + const tError = useTranslations("core.global.errors"); + const [isPending, startTransition] = React.useTransition(); + + return ( + + ); +}; + +export const AiModelsContent = ({ models, ...props }: AiModelsContentProps) => { + const t = useTranslations("admin.ai.models"); + + if (models.length === 0) { + return ( + + + {t("empty.title")} + {t("empty.desc")} + + + ); + } + + return ( +
+ {models.map(model => ( + + ))} +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/ai/models/pricing-form-content.tsx b/packages/vitnode/src/views/admin/views/core/ai/models/pricing-form-content.tsx new file mode 100644 index 000000000..a073fcdc4 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/models/pricing-form-content.tsx @@ -0,0 +1,155 @@ +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { + AutoFormOnSubmit, + ItemAutoFormComponentProps, +} from "@/components/form/auto-form"; +import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormNullableNumber } from "@/components/form/fields/nullable-number"; +import { AutoFormNumber } from "@/components/form/fields/number"; +import { useDialog } from "@/components/ui/dialog"; + +import type { AdminAiModel, AdminAiPricing } from "../ai-query"; + +import { toAiDecimal } from "../ai-decimal"; + +const PRICE_FORMAT = { maximumFractionDigits: 6 } as const; + +const optionalRate = (value: string | undefined): null | number => + value === undefined ? null : Number(value); + +export interface AiPricingFormProps { + model: AdminAiModel; + onSave: (body: { + modelId: string; + pricing: AdminAiPricing; + }) => Promise>; +} + +export const AiPricingFormContent = ({ model, onSave }: AiPricingFormProps) => { + const t = useTranslations("admin.ai.models.pricing_form"); + const tError = useTranslations("core.global.errors"); + const { setIsDirty, setOpen } = useDialog(); + const rates = model.pricing?.pricing.rates; + + const optional = (value: string | undefined) => + z.number().min(0).nullable().default(optionalRate(value)); + + const formSchema = z.object({ + inputPerMillion: z + .number() + .min(0) + .default(Number(rates?.inputPerMillion ?? 0)), + outputPerMillion: z + .number() + .min(0) + .default(Number(rates?.outputPerMillion ?? 0)), + cacheReadPerMillion: optional(rates?.cacheReadPerMillion), + cacheWritePerMillion: optional(rates?.cacheWritePerMillion), + perRequest: optional(rates?.perRequest), + perImage: optional(rates?.perImage), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const optionalPrice = ( + key: TKey, + value: null | number, + ): Partial> => + value === null + ? {} + : ({ [key]: toAiDecimal(value) } as Record); + const pricing: AdminAiPricing = { + rates: { + inputPerMillion: toAiDecimal(values.inputPerMillion), + outputPerMillion: toAiDecimal(values.outputPerMillion), + ...optionalPrice("cacheReadPerMillion", values.cacheReadPerMillion), + ...optionalPrice("cacheWritePerMillion", values.cacheWritePerMillion), + ...optionalPrice("perRequest", values.perRequest), + ...optionalPrice("perImage", values.perImage), + }, + // Long-context tiers have no editor here; an override keeps them. + ...(model.pricing?.pricing.tiers + ? { tiers: model.pricing.pricing.tiers } + : {}), + }; + + const result = await onSave({ modelId: model.id, pricing }); + + if ("error" in result) { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + + return; + } + + toast.success(t("saved.title"), { + description: t("saved.desc", { name: model.name }), + }); + setIsDirty?.(false); + setOpen?.(false); + }; + + const rate = + (label: string, description?: string) => + (props: ItemAutoFormComponentProps) => ( + + ); + + const optionalField = + (label: string, unit: string) => (props: ItemAutoFormComponentProps) => ( + + ); + + return ( + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/ai/overview/overview-content.test.tsx b/packages/vitnode/src/views/admin/views/core/ai/overview/overview-content.test.tsx new file mode 100644 index 000000000..2caf931fb --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/overview/overview-content.test.tsx @@ -0,0 +1,121 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AdminAiOverview } from "../ai-query"; + +import { AiOverviewContent } from "./overview-content"; + +const overview = ( + overrides: Partial = {}, +): AdminAiOverview => ({ + alt: { + imagesDescribed: 0, + knownCostUsd: "0", + perImageUsd: null, + perTranslationUsd: null, + translations: 0, + }, + averageDailyCostUsd: "0.5", + averageOperationCostUsd: null, + budget: { + limitUsd: "25", + remainingUsd: "20", + reservedUsd: "0.1", + spentUsd: "5", + systemLimitUsd: null, + systemSpentUsd: "0", + }, + byAction: [ + { + failures: 1, + key: "@vitnode/blog:excerpt.generate", + knownCostUsd: "0", + knownOperations: 0, + operations: 4, + }, + ], + byModel: [], + byOrigin: [], + costSources: { estimated: 0, provider: 0, unknown: 4 }, + enabled: true, + failureRate: 0.25, + knownCostUsd: "0", + operations: 4, + period: { + end: "2026-11-01T00:00:00.000Z", + start: "2026-10-01T00:00:00.000Z", + }, + pricingCoverage: 0, + tokens: { input: 1200, output: 300 }, + ...overrides, +}); + +const renderOverview = (data: AdminAiOverview) => + render( + + + key === "@vitnode/blog:excerpt.generate" ? "Write an excerpt" : null + } + /> + , + ); + +describe("AiOverviewContent", () => { + beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class { + disconnect() {} + observe() {} + unobserve() {} + }, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("says a cost is unknown rather than showing it as zero", () => { + renderOverview(overview()); + + // The average, both ALT averages and the action's known cost. + expect(screen.getAllByText("admin.ai.cost.unknown")).toHaveLength(4); + expect( + screen.getByText("admin.ai.overview.coverage.unknown_hint"), + ).toBeTruthy(); + }); + + it("names an action by its description and keeps its key", () => { + renderOverview(overview()); + + expect(screen.getByText("Write an excerpt")).toBeTruthy(); + expect(screen.getByText("@vitnode/blog:excerpt.generate")).toBeTruthy(); + }); + + it("switches the breakdown between actions, models and origins", () => { + renderOverview(overview()); + + fireEvent.click( + screen.getByRole("tab", { + name: "admin.ai.overview.breakdown.by_model", + }), + ); + + expect(screen.getByText("admin.ai.overview.breakdown.empty")).toBeTruthy(); + }); + + it("shows how much of the monthly budget is spent", () => { + renderOverview(overview()); + + const bar = screen.getByRole("progressbar", { + name: "admin.ai.overview.budget.spent", + }); + + expect(bar.getAttribute("aria-valuenow")).toBe("5"); + expect(bar.getAttribute("aria-valuemax")).toBe("25"); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/ai/overview/overview-content.tsx b/packages/vitnode/src/views/admin/views/core/ai/overview/overview-content.tsx new file mode 100644 index 000000000..a457e4ade --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/overview/overview-content.tsx @@ -0,0 +1,388 @@ +import { Link } from "@tanstack/react-router"; +import { useFormatter, useLocale, useTranslations } from "use-intl"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Progress, ProgressLabel } from "@/components/ui/progress"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tabs, + TabsContent, + TabsList, + TabsPanels, + TabsTrigger, +} from "@/components/ui/tabs"; +import { formatAiUsd } from "@/lib/ai/format-points"; + +import type { AdminAiOverview, AiBreakdownRow } from "../ai-query"; + +import { AiActionLabel, AiUsd, formatAiShare } from "../ai-labels"; + +const AiStatCard = ({ + children, + label, + value, +}: { + children?: React.ReactNode; + label: React.ReactNode; + value: React.ReactNode; +}) => ( + + + {label} + + {value} + + + {children ? ( + + {children} + + ) : null} + +); + +const AiBudgetCard = ({ data }: { data: AdminAiOverview }) => { + const t = useTranslations("admin.ai.overview.budget"); + const locale = useLocale(); + const { limitUsd, remainingUsd, reservedUsd, spentUsd } = data.budget; + const limit = limitUsd === null ? null : Number(limitUsd); + + return ( + }> + {limit === null ? ( + + {t("no_limit")}{" "} + + {t("set_limit")} + + + ) : ( + + t("of_limit", { + limit: formatAiUsd(limitUsd, locale), + spent: formatAiUsd(spentUsd, locale), + }) + } + max={Math.max(limit, 0)} + min={0} + value={Math.min(Number(spentUsd), Math.max(limit, 0))} + > + {t("spent")} + + {t("remaining", { remaining: formatAiUsd(remainingUsd, locale) })} + + + )} + + {t("reserved", { reserved: formatAiUsd(reservedUsd, locale) })} + + + ); +}; + +const AiCoverageCard = ({ data }: { data: AdminAiOverview }) => { + const t = useTranslations("admin.ai.overview.coverage"); + const locale = useLocale(); + const { estimated, provider, unknown } = data.costSources; + + return ( + + + {t("title")} + + {t("summary", { + share: formatAiShare(data.pricingCoverage, locale), + })} + + + +
+ {( + [ + ["provider", provider], + ["estimated", estimated], + ["unknown", unknown], + ] as const + ).map(([key, value]) => ( +
+
{t(key)}
+
{value}
+
+ ))} +
+

+ {unknown > 0 ? t("unknown_hint") : t("all_known_hint")} +

+
+
+ ); +}; + +const AiAltCard = ({ data }: { data: AdminAiOverview }) => { + const t = useTranslations("admin.ai.overview.alt"); + + return ( + + + {t("title")} + + {t("desc")} + + + +
+
+
{t("images")}
+
+ {data.alt.imagesDescribed} +
+
+
+
+ {t("translations")} +
+
+ {data.alt.translations} +
+
+
+
{t("per_image")}
+
+ +
+
+
+
+ {t("per_translation")} +
+
+ +
+
+
+
+
+ ); +}; + +const AiBreakdownTable = ({ + label, + rows, +}: { + label: (key: string) => React.ReactNode; + rows: AiBreakdownRow[]; +}) => { + const t = useTranslations("admin.ai.overview.breakdown"); + const locale = useLocale(); + + if (rows.length === 0) { + return ( +

+ {t("empty")} +

+ ); + } + + return ( + + + + {t("name")} + {t("operations")} + {t("failures")} + {t("known_cost")} + {t("coverage")} + + + + {rows.map(row => ( + + {label(row.key)} + + {row.operations} + + + {row.failures} + + + 0 + ? null + : row.knownCostUsd + } + /> + + + {formatAiShare( + row.operations === 0 ? 1 : row.knownOperations / row.operations, + locale, + )} + + + ))} + +
+ ); +}; + +const AiBreakdownCard = ({ + data, + describeAction, +}: { + data: AdminAiOverview; + describeAction: (key: string) => null | string; +}) => { + const t = useTranslations("admin.ai.overview.breakdown"); + const tOrigin = useTranslations("admin.ai.origin"); + + return ( + + + {t("title")} + + + + + {t("by_action")} + {t("by_model")} + {t("by_origin")} + + + + ( + + )} + rows={data.byAction} + /> + + + {key}} + rows={data.byModel} + /> + + + + key === "system" || key === "user" ? tOrigin(key) : key + } + rows={data.byOrigin} + /> + + + + + + ); +}; + +export const AiOverviewContent = ({ + data, + describeAction, +}: { + data: AdminAiOverview; + describeAction: (key: string) => null | string; +}) => { + const t = useTranslations("admin.ai.overview"); + const locale = useLocale(); + const format = useFormatter(); + + return ( +
+ {data.enabled ? null : ( + + {t("disabled.title")} + + {t("disabled.desc")}{" "} + {t("disabled.link")} + + + )} + +

+ {t("period_range", { + end: format.dateTime(new Date(data.period.end), { + dateStyle: "medium", + }), + start: format.dateTime(new Date(data.period.start), { + dateStyle: "medium", + }), + })} +

+ +
+ + + {t("failure_rate", { + share: formatAiShare(data.failureRate, locale), + })} + + } + > + {t("average_daily", { + amount: formatAiUsd(data.averageDailyCostUsd, locale), + })} + + + {t("tokens_split", { + input: format.number(data.tokens.input), + output: format.number(data.tokens.output), + })} + +
+ +
+ + +
+ + {data.budget.systemLimitUsd === null && + Number(data.budget.systemSpentUsd) === 0 ? null : ( +

+ {t("system_budget", { + limit: + data.budget.systemLimitUsd === null + ? t("system_no_limit") + : formatAiUsd(data.budget.systemLimitUsd, locale), + spent: formatAiUsd(data.budget.systemSpentUsd, locale), + })} +

+ )} + + +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/ai/settings/settings-form-content.tsx b/packages/vitnode/src/views/admin/views/core/ai/settings/settings-form-content.tsx new file mode 100644 index 000000000..08c80a41c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/ai/settings/settings-form-content.tsx @@ -0,0 +1,324 @@ +import { toast } from "sonner"; +import { useLocale, useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; +import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { AutoFormNullableNumber } from "@/components/form/fields/nullable-number"; +import { AutoFormNumber } from "@/components/form/fields/number"; +import { AutoFormSwitch } from "@/components/form/fields/switch"; +import { formatAiUsd } from "@/lib/ai/format-points"; + +import type { AdminAiSettingsInput } from "../ai-mutations"; +import type { AdminAiSettings } from "../ai-query"; + +import { fromAiDecimal, toAiDecimal } from "../ai-decimal"; + +const USD_FORMAT = { + maximumFractionDigits: 2, + minimumFractionDigits: 0, +} as const; + +const parseLanguages = (value: string): null | string[] => { + const codes = [ + ...new Set( + value + .split(",") + .map(code => code.trim()) + .filter(Boolean), + ), + ]; + + return codes.length > 0 ? codes : null; +}; + +export interface AiSettingsFormProps { + canManage: boolean; + data: AdminAiSettings; + onSave: (values: AdminAiSettingsInput) => Promise>; +} + +export const AiSettingsFormContent = ({ + canManage, + data, + onSave, +}: AiSettingsFormProps) => { + const t = useTranslations("admin.ai.settings"); + const tError = useTranslations("core.global.errors"); + const locale = useLocale(); + + const formSchema = z + .object({ + enabled: z.boolean().default(data.enabled), + monthlyBudgetUsd: z + .number() + .min(0) + .nullable() + .default(fromAiDecimal(data.monthlyBudgetUsd)), + systemMonthlyBudgetUsd: z + .number() + .min(0) + .nullable() + .default(fromAiDecimal(data.systemMonthlyBudgetUsd)), + defaultMonthlyPoints: z + .number() + .min(0) + .default(Number(data.defaultMonthlyPoints)), + userRequestsPerMinute: z + .number() + .int() + .min(1) + .max(600) + .default(data.userRequestsPerMinute), + userConcurrency: z + .number() + .int() + .min(1) + .max(20) + .default(data.userConcurrency), + systemConcurrency: z + .number() + .int() + .min(1) + .max(20) + .default(data.systemConcurrency), + historyRetentionDays: z + .number() + .int() + .min(7) + .max(3650) + .default(data.historyRetentionDays), + altEnabled: z.boolean().default(data.altEnabled), + altLanguages: z + .string() + .max(500) + .default(data.altLanguages?.join(", ") ?? ""), + altBatchSize: z.number().int().min(1).max(100).default(data.altBatchSize), + }) + .refine(values => !values.altEnabled || values.monthlyBudgetUsd !== null, { + message: t("alt.needs_budget"), + path: ["altEnabled"], + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const result = await onSave({ + altBatchSize: values.altBatchSize, + altEnabled: values.altEnabled, + altLanguages: parseLanguages(values.altLanguages), + defaultMonthlyPoints: toAiDecimal(values.defaultMonthlyPoints), + enabled: values.enabled, + historyRetentionDays: values.historyRetentionDays, + monthlyBudgetUsd: + values.monthlyBudgetUsd === null + ? null + : toAiDecimal(values.monthlyBudgetUsd, 2), + systemConcurrency: values.systemConcurrency, + systemMonthlyBudgetUsd: + values.systemMonthlyBudgetUsd === null + ? null + : toAiDecimal(values.systemMonthlyBudgetUsd, 2), + userConcurrency: values.userConcurrency, + userRequestsPerMinute: values.userRequestsPerMinute, + }); + + if ("error" in result) { + toast.error(tError("title"), { + description: + result.error.status === 400 && result.error.message + ? result.error.message + : tError("internal_server_error"), + }); + + return; + } + + toast.success(t("saved.title"), { description: t("saved.desc") }); + }; + + return ( + ( + + ), + id: "enabled", + tab: "budget", + }, + { + component: props => ( + + ), + id: "monthlyBudgetUsd", + tab: "budget", + }, + { + component: props => ( + + ), + id: "systemMonthlyBudgetUsd", + tab: "budget", + }, + { + component: props => ( + + ), + id: "defaultMonthlyPoints", + tab: "budget", + }, + { + component: props => ( + + ), + id: "userRequestsPerMinute", + tab: "limits", + }, + { + component: props => ( + + ), + id: "userConcurrency", + tab: "limits", + }, + { + component: props => ( + + ), + id: "systemConcurrency", + tab: "limits", + }, + { + component: props => ( + + ), + id: "historyRetentionDays", + tab: "limits", + }, + { + children: [ + { + component: props => ( + + ), + id: "altLanguages", + }, + { + component: props => ( + + ), + id: "altBatchSize", + }, + ], + component: props => ( + + ), + id: "altEnabled", + tab: "alt", + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ + children: t("submit"), + disabled: !canManage, + }} + tabs={[ + { label: t("tabs.budget"), value: "budget" }, + { label: t("tabs.limits"), value: "limits" }, + { label: t("tabs.alt"), value: "alt" }, + ]} + /> + ); +}; diff --git a/packages/vitnode/src/views/auth/settings/ai/ai-history-content.tsx b/packages/vitnode/src/views/auth/settings/ai/ai-history-content.tsx new file mode 100644 index 000000000..4e5509e90 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/ai/ai-history-content.tsx @@ -0,0 +1,105 @@ +import { cn } from "cn"; +import { useLocale, useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatAiPoints } from "@/lib/ai/format-points"; +import { aiRunStatusVariant, isAiRunStatus } from "@/lib/ai/run-status"; + +import type { AiHistoryItem, AiUsageAction } from "./ai-usage-query"; + +import { SETTINGS_ROW, SettingsGroup } from "../settings-group"; + +const AiRunStatusLabel = ({ status }: { status: string }) => { + const t = useTranslations("core.auth.settings.ai.status"); + + return ( + + {isAiRunStatus(status) ? t(status) : status} + + ); +}; + +const AiHistoryRow = ({ + description, + item, +}: { + description: null | string; + item: AiHistoryItem; +}) => { + const t = useTranslations("core.auth.settings.ai.history"); + const locale = useLocale(); + + return ( +
  • +
    + + {description ?? item.actionKey} + + + + +
    +
    + + + {item.chargedPoints === null + ? t("no_charge") + : t("points", { + points: formatAiPoints(item.chargedPoints, locale), + })} + +
    +
  • + ); +}; + +export const AiHistoryContent = ({ + actions, + hasNextPage, + isFetchingNextPage, + items, + onLoadMore, +}: { + actions: AiUsageAction[]; + hasNextPage: boolean; + isFetchingNextPage: boolean; + items: AiHistoryItem[]; + onLoadMore: () => void; +}) => { + const t = useTranslations("core.auth.settings.ai.history"); + const descriptions = new Map( + actions.map(action => [action.key, action.description]), + ); + + return ( +
    + + {items.length === 0 ? ( +
  • + {t("empty")} +
  • + ) : ( + items.map(item => ( + + )) + )} +
    + {hasNextPage ? ( + + ) : null} +
    + ); +}; diff --git a/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx new file mode 100644 index 000000000..b32928b37 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx @@ -0,0 +1,169 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { describe, expect, it, vi } from "vitest"; + +import type { AiHistoryItem, AiUsage } from "./ai-usage-query"; + +import { AiHistoryContent } from "./ai-history-content"; +import { AiUsageContent } from "./ai-usage-content"; + +const NOTICE = "core.auth.settings.ai.notice"; + +const usage = (overrides: Partial = {}): AiUsage => ({ + actions: [ + { + dailyLimit: 5, + description: "Write an excerpt", + key: "@vitnode/blog:excerpt.generate", + permissionKey: "@vitnode/blog:excerpt", + usedToday: 2, + }, + ], + enabled: true, + notice: "none", + points: { available: "60", reserved: "0", total: "100", used: "40" }, + resetsAt: "2026-11-01T00:00:00.000Z", + sitePaused: false, + ...overrides, +}); + +const renderInIntl = (ui: React.ReactNode) => + render( + + {ui} + , + ); + +describe("AiUsageContent", () => { + it("warns once most of the allowance is gone", () => { + renderInIntl(); + + expect(screen.getByText(`${NOTICE}.near_limit.title`)).toBeTruthy(); + expect(screen.queryByText(`${NOTICE}.exhausted.title`)).toBeNull(); + expect(screen.queryByText(`${NOTICE}.site_paused.title`)).toBeNull(); + }); + + it("says the allowance is spent when it is", () => { + renderInIntl( + , + ); + + expect(screen.getByRole("alert").textContent).toContain( + `${NOTICE}.exhausted.title`, + ); + expect(screen.queryByText(`${NOTICE}.near_limit.title`)).toBeNull(); + }); + + it("blames the site budget, not the member, when the site is paused", () => { + renderInIntl( + , + ); + + expect(screen.getByText(`${NOTICE}.site_paused.title`)).toBeTruthy(); + expect(screen.queryByText(`${NOTICE}.exhausted.title`)).toBeNull(); + expect(screen.queryByText(`${NOTICE}.near_limit.title`)).toBeNull(); + }); + + it("shows no notice while there is room left", () => { + renderInIntl(); + + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByText(`${NOTICE}.site_paused.title`)).toBeNull(); + }); + + it("reports the points used as an accessible progress bar", () => { + renderInIntl(); + + const bar = screen.getByRole("progressbar", { + name: "core.auth.settings.ai.points.used", + }); + + expect(bar.getAttribute("aria-valuenow")).toBe("40"); + expect(bar.getAttribute("aria-valuemax")).toBe("100"); + }); + + it("draws no bar for an unlimited allowance", () => { + renderInIntl( + , + ); + + expect(screen.queryByRole("progressbar")).toBeNull(); + expect( + screen.getByText("core.auth.settings.ai.points.unlimited"), + ).toBeTruthy(); + }); + + it("lists each feature's daily limit with its key", () => { + renderInIntl(); + + expect(screen.getByText("Write an excerpt")).toBeTruthy(); + expect(screen.getByText("@vitnode/blog:excerpt.generate")).toBeTruthy(); + }); +}); + +describe("AiHistoryContent", () => { + const item: AiHistoryItem = { + accepted: null, + actionKey: "@vitnode/blog:excerpt.generate", + chargedPoints: "0.04", + createdAt: "2026-10-01T10:00:00.000Z", + errorCode: null, + id: 7, + status: "succeeded", + }; + + it("names an operation by what it does and loads more on request", () => { + const onLoadMore = vi.fn(); + + renderInIntl( + , + ); + + expect(screen.getByText("Write an excerpt")).toBeTruthy(); + expect( + screen.getByText("core.auth.settings.ai.status.succeeded"), + ).toBeTruthy(); + + fireEvent.click( + screen.getByRole("button", { + name: "core.auth.settings.ai.history.load_more", + }), + ); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); + + it("offers nothing more once the last page is shown", () => { + renderInIntl( + , + ); + + expect( + screen.getByText("core.auth.settings.ai.history.empty"), + ).toBeTruthy(); + expect(screen.queryByRole("button")).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.tsx b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.tsx new file mode 100644 index 000000000..e0b24f9f4 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.tsx @@ -0,0 +1,192 @@ +import { cn } from "cn"; +import { useFormatter, useLocale, useTranslations } from "use-intl"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Progress, ProgressLabel } from "@/components/ui/progress"; +import { formatAiPoints } from "@/lib/ai/format-points"; + +import type { AiUsage, AiUsageAction } from "./ai-usage-query"; + +import { + SETTINGS_ROW, + SETTINGS_ROW_LABEL, + SettingsGroup, +} from "../settings-group"; + +const AiUsageNoticeContent = ({ usage }: { usage: AiUsage }) => { + const t = useTranslations("core.auth.settings.ai.notice"); + const format = useFormatter(); + const resetsAt = format.dateTime(new Date(usage.resetsAt), { + dateStyle: "long", + }); + + if (!usage.enabled) { + return ( + + {t("disabled.title")} + {t("disabled.desc")} + + ); + } + + // The site's own budget is checked first: a paused site is never the + // member's doing, and telling them their allowance is the problem would be + // wrong even when it happens to be low as well. + if (usage.sitePaused || usage.notice === "site_paused") { + return ( + + {t("site_paused.title")} + {t("site_paused.desc")} + + ); + } + + if (usage.notice === "exhausted") { + return ( + + {t("exhausted.title")} + {t("exhausted.desc", { resetsAt })} + + ); + } + + if (usage.notice === "near_limit") { + return ( + + {t("near_limit.title")} + + {t("near_limit.desc", { resetsAt })} + + + ); + } + + return null; +}; + +const AiPointsRow = ({ + label, + value, +}: { + label: React.ReactNode; + value: React.ReactNode; +}) => ( +
  • + {label} + + {value} + +
  • +); + +export const AiPointsContent = ({ usage }: { usage: AiUsage }) => { + const t = useTranslations("core.auth.settings.ai.points"); + const locale = useLocale(); + const format = useFormatter(); + const { available, reserved, total, used } = usage.points; + const usedLabel = formatAiPoints(used, locale); + const totalLabel = formatAiPoints(total, locale); + const totalValue = total === null ? null : Number(total); + const usedValue = Number(used); + + return ( + +
  • + {totalValue === null ? ( +
    + {t("used")} + + {t("used_unlimited", { used: usedLabel })} + +
    + ) : ( + + t("used_of", { total: totalLabel, used: usedLabel }) + } + max={Math.max(totalValue, 0)} + min={0} + value={Math.min(Math.max(usedValue, 0), Math.max(totalValue, 0))} + > + {t("used")} + + {t("used_of", { total: totalLabel, used: usedLabel })} + + + )} +
  • + + + +
    + ); +}; + +const AiDailyLimitRow = ({ action }: { action: AiUsageAction }) => { + const t = useTranslations("core.auth.settings.ai.daily"); + + return ( +
  • +
    + + {action.description ?? action.key} + + {action.description ? ( + + {action.key} + + ) : null} +
    + + {action.dailyLimit === null + ? t("no_limit", { used: action.usedToday }) + : t("used_of", { limit: action.dailyLimit, used: action.usedToday })} + +
  • + ); +}; + +export const AiDailyLimitsContent = ({ + actions, +}: { + actions: AiUsageAction[]; +}) => { + const t = useTranslations("core.auth.settings.ai.daily"); + + return ( + + {actions.length === 0 ? ( +
  • + {t("empty")} +
  • + ) : ( + actions.map(action => ( + + )) + )} +
    + ); +}; + +export const AiUsageContent = ({ usage }: { usage: AiUsage }) => ( +
    + + + +
    +); diff --git a/packages/vitnode/src/views/auth/settings/ai/ai-usage-query.ts b/packages/vitnode/src/views/auth/settings/ai/ai-usage-query.ts new file mode 100644 index 000000000..30bb1e757 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/ai/ai-usage-query.ts @@ -0,0 +1,127 @@ +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; + +import { CONFIG_PLUGIN } from "@/config"; +import { OPERATIONAL_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; + +export const AI_USAGE_NOTICES = [ + "none", + "near_limit", + "exhausted", + "site_paused", +] as const; +export type AiUsageNotice = (typeof AI_USAGE_NOTICES)[number]; + +export interface AiUsageAction { + dailyLimit: null | number; + description: null | string; + key: string; + permissionKey: string; + usedToday: number; +} + +/** `GET /ai/usage` - the signed-in user's own allowance. */ +export interface AiUsage { + actions: AiUsageAction[]; + enabled: boolean; + notice: AiUsageNotice; + points: { + available: null | string; + reserved: string; + /** `null` is unlimited. */ + total: null | string; + used: string; + }; + resetsAt: string; + sitePaused: boolean; +} + +export interface AiHistoryItem { + accepted: boolean | null; + actionKey: string; + chargedPoints: null | string; + createdAt: Date | string; + errorCode: null | string; + id: number; + status: string; +} + +export interface AiHistoryPage { + items: AiHistoryItem[]; + nextBefore: null | number; +} + +export class AiUsageRequestError extends Error { + constructor(status: number, what: string) { + super(`The AI API answered ${status} for ${what}.`); + this.name = "AiUsageRequestError"; + this.status = status; + } + + readonly status: number; +} + +export type AiUsageFetcher = () => Promise; + +export const fetchAiUsage: AiUsageFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "ai", + path: "/usage", + }); + + if (!response.ok) { + throw new AiUsageRequestError(response.status, "the user's AI usage"); + } + + return await response.json(); +}; + +export type AiHistoryFetcher = (before?: number) => Promise; + +export const fetchAiHistory: AiHistoryFetcher = async before => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: before === undefined ? {} : { before } }, + method: "get", + module: "ai", + path: "/history", + }); + + if (!response.ok) { + throw new AiUsageRequestError( + response.status, + `the user's AI history (before ${before ?? "none"})`, + ); + } + + return await response.json(); +}; + +export const AI_USAGE_QUERY_ROOT = ["ai", "user"] as const; + +export const aiUsageQueryRoot = (userId: number) => + [...AI_USAGE_QUERY_ROOT, userId] as const; + +export const aiUsageQueryOptions = ({ userId }: { userId: number }) => + queryOptions({ + // The owner comes from the session cookie on the server; `userId` only + // keeps one account's cache from answering for another. + queryFn: async () => await fetchAiUsage(), + queryKey: [...aiUsageQueryRoot(userId), "usage"] as const, + retry: false, + /** {@link OPERATIONAL_STALE_TIME} - Points move every time an AI feature runs. */ + staleTime: OPERATIONAL_STALE_TIME, + }); + +export const aiHistoryQueryOptions = ({ userId }: { userId: number }) => + infiniteQueryOptions({ + getNextPageParam: (lastPage: AiHistoryPage) => + lastPage.nextBefore ?? undefined, + initialPageParam: undefined as number | undefined, + queryFn: async ({ pageParam }) => await fetchAiHistory(pageParam), + queryKey: [...aiUsageQueryRoot(userId), "history"] as const, + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); diff --git a/packages/vitnode/src/views/auth/settings/nav-content.tsx b/packages/vitnode/src/views/auth/settings/nav-content.tsx index cdf595474..cda241474 100644 --- a/packages/vitnode/src/views/auth/settings/nav-content.tsx +++ b/packages/vitnode/src/views/auth/settings/nav-content.tsx @@ -6,6 +6,7 @@ import { LinkIcon, MenuIcon, MonitorSmartphoneIcon, + SparklesIcon, UserRoundIcon, XIcon, } from "lucide-react"; @@ -29,6 +30,7 @@ const ICONS: Record< SettingsNavKey, React.ComponentType<{ className?: string }> > = { + ai: SparklesIcon, devices: MonitorSmartphoneIcon, notifications: BellIcon, overview: UserRoundIcon, diff --git a/packages/vitnode/src/views/auth/settings/settings-nav.test.ts b/packages/vitnode/src/views/auth/settings/settings-nav.test.ts index 65219d9f6..ebb5f2ab6 100644 --- a/packages/vitnode/src/views/auth/settings/settings-nav.test.ts +++ b/packages/vitnode/src/views/auth/settings/settings-nav.test.ts @@ -28,4 +28,18 @@ describe("visibleSettingsNavItems", () => { ), ).toEqual(["overview", "notifications", "devices"]); }); + + it("shows AI usage only while the site has an AI model configured", () => { + expect( + visibleSettingsNavItems({ + ai: { models: [{ id: "fast" }] }, + passkeys: false, + }).map(item => item.key), + ).toEqual(["overview", "devices", "ai"]); + expect( + visibleSettingsNavItems({ ai: { models: [] }, passkeys: false }).map( + item => item.key, + ), + ).toEqual(["overview", "devices"]); + }); }); diff --git a/packages/vitnode/src/views/auth/settings/settings-nav.ts b/packages/vitnode/src/views/auth/settings/settings-nav.ts index b98ff24b4..fcbb88784 100644 --- a/packages/vitnode/src/views/auth/settings/settings-nav.ts +++ b/packages/vitnode/src/views/auth/settings/settings-nav.ts @@ -4,7 +4,12 @@ import { normalizeUrl } from "@/lib/utils"; export const SETTINGS_ROOT_HREF = "/settings"; export type SettingsNavKey = - "devices" | "notifications" | "overview" | "security" | "sso"; + | "ai" + | "devices" + | "notifications" + | "overview" + | "security" + | "sso"; export interface SettingsNavItem { href: string; @@ -18,19 +23,24 @@ export const SETTINGS_NAV_ITEMS: readonly SettingsNavItem[] = [ { href: "/settings/devices", key: "devices" }, { href: "/settings/security", key: "security" }, { href: "/settings/sso", key: "sso" }, + { href: "/settings/ai", key: "ai" }, ]; export const visibleSettingsNavItems = ({ + ai, passkeys, sso = [], }: { + /** The site's configured AI models - the panel is hidden without any. */ + ai?: { models: readonly unknown[] }; passkeys: boolean; sso?: readonly unknown[]; }): readonly SettingsNavItem[] => SETTINGS_NAV_ITEMS.filter( item => (item.key !== "security" || passkeys) && - (item.key !== "sso" || sso.length > 0), + (item.key !== "sso" || sso.length > 0) && + (item.key !== "ai" || (ai?.models.length ?? 0) > 0), ); /** Whether one navigation item is the panel `pathname` is showing. */ diff --git a/plugins/blog/src/content/post.ts b/plugins/blog/src/content/post.ts index 05a84c7d3..4b3d90a6c 100644 --- a/plugins/blog/src/content/post.ts +++ b/plugins/blog/src/content/post.ts @@ -52,6 +52,11 @@ export const blogPostContentType = defineContentType({ }), content: field.textarea({ localized: true, required: true }), excerpt: field.textarea({ + ai: { + action: "@vitnode/blog:excerpt.generate", + mode: "suggestion", + sourceFields: ["title", "content"], + }, localized: true, nullable: true, maxLength: 300, diff --git a/plugins/blog/src/views/admin/article/editor/ai.ts b/plugins/blog/src/views/admin/article/editor/ai.ts index 96a2f7473..64b41322a 100644 --- a/plugins/blog/src/views/admin/article/editor/ai.ts +++ b/plugins/blog/src/views/admin/article/editor/ai.ts @@ -1,3 +1,7 @@ +import { + AiRequestError, + requestAiAssist, +} from "@vitnode/core/lib/ai/assist-client"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { CONFIG_PLUGIN } from "@/const"; @@ -32,21 +36,30 @@ export const translateArticleText = async (body: { return text; }; -export const writeArticleExcerpt = async (body: { +/** + * Writes an excerpt through Core's shared AI assist route - the same action + * and mechanism the generic `ai` field option uses, so limits, history and + * accounting are identical wherever the button is. + */ +export const writeArticleExcerpt = async (input: { content: string; locale: string; title: string; }): Promise => { - const response = await fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - method: "post", - module: "admin/ai", - path: "/excerpt", - args: { body }, - }); - if (response.status !== 200) throw new ArticleAiError(response.status); - - const { text } = await response.json(); + try { + const { output } = await requestAiAssist({ + action: `${CONFIG_PLUGIN.pluginId}:excerpt.generate`, + input, + }); + if (typeof output !== "string") throw new ArticleAiError(502); - return text; + return output; + } catch (error) { + if (error instanceof AiRequestError) { + throw new ArticleAiError( + error.code === "AI_NOT_CONFIGURED" ? 400 : error.status, + ); + } + throw error; + } }; diff --git a/plugins/example/src/api/ai/actions.ts b/plugins/example/src/api/ai/actions.ts new file mode 100644 index 000000000..0d46b9511 --- /dev/null +++ b/plugins/example/src/api/ai/actions.ts @@ -0,0 +1,52 @@ +import { defineAiAction } from "@vitnode/core/api/lib/ai/action"; +import { checkStaffPermission } from "@vitnode/core/api/lib/check-staff-permission"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; +import { articleContentType } from "@/content/article"; + +const languageName = (code: string) => { + try { + return new Intl.DisplayNames(["en"], { type: "language" }).of(code) ?? code; + } catch { + return code; + } +}; + +/** + * Suggests an article teaser from its title and code. The whole feature is + * this definition plus `ai` on the field: the AdminCP form, the shared + * assist route, limits and accounting all come from Core. + */ +export const articleExcerptAiAction = defineAiAction({ + authorize: async ({ c }) => + await checkStaffPermission(c, { + module: articleContentType.permissionModule, + permission: "can_edit", + plugin: CONFIG_PLUGIN.pluginId, + type: "admin", + }), + buildPrompt: (input, { instructions }) => ({ + prompt: `Title: ${input.title}\nProduct code: ${input.code}`, + system: [ + "You write a one-sentence teaser for an article in a demo catalogue.", + `Write it in ${languageName(input.locale)}, under 200 characters.`, + "Answer with the teaser only, without quotes.", + ...(instructions ? [instructions] : []), + ].join("\n"), + }), + defaults: { maxInputCharacters: 400, maxOutputTokens: 120, timeoutMs: 20_000 }, + description: "Suggests a teaser for an example article.", + id: "article.excerpt", + inputSchema: z.object({ + code: z.string().max(100), + locale: z.string().min(2).max(16), + title: z.string().min(1).max(200), + }), + output: "text", + outputSchema: z.string().min(1).max(500), + parseText: text => text.trim().replace(/^["“](.*)["”]$/su, "$1"), + permission: { defaultGranted: true, key: "article.excerpt" }, + promptVersion: 1, + requiredCapabilities: ["text"], +}); diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts index d500f112d..9261faa00 100644 --- a/plugins/example/src/config.api.ts +++ b/plugins/example/src/config.api.ts @@ -3,6 +3,7 @@ import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin"; import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; import { buildContentPublicModule } from "@vitnode/core/content/server"; +import { articleExcerptAiAction } from "@/api/ai/actions"; import { adminModule } from "@/api/modules/admin/admin.module"; import { CONFIG_PLUGIN } from "@/const"; import { settingsPage } from "@/content/settings-page"; @@ -18,6 +19,7 @@ import "@/api/lib/events"; export const exampleApiPlugin = () => buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, + aiActions: [articleExcerptAiAction], blocks: widgets, editablePages: [settingsPage], navigation: [{ href: "/example/browse", icon: "list", id: "browse" }], diff --git a/plugins/example/src/content/article.ts b/plugins/example/src/content/article.ts index 3f924916e..8463ef3c2 100644 --- a/plugins/example/src/content/article.ts +++ b/plugins/example/src/content/article.ts @@ -13,7 +13,17 @@ export const articleContentType = defineContentType({ slug: field.slug({ source: "title" }), // `unique: true` is all it takes to get a unique index in the migration. code: field.text({ required: true, maxLength: 100, unique: true }), - excerpt: field.textarea({ maxLength: 500, nullable: true }), + // AI assistance is metadata on the field: the AdminCP shows an AI button + // that suggests a teaser from these fields. Nothing else to build. + excerpt: field.textarea({ + ai: { + action: "@vitnode/example:article.excerpt", + mode: "suggestion", + sourceFields: ["title", "code"], + }, + maxLength: 500, + nullable: true, + }), views: field.number({ integer: true, min: 0, defaultValue: 0 }), featured: field.boolean({ defaultValue: false }), From c9e45a53f2ddba8fdafa13c344acb3c73c0679ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:03:03 +0000 Subject: [PATCH 05/14] feat(ai): Quick Ask in the rich text editor A toolbar action streams shorten/correct/simplify/tone rewrites, continue writing and custom requests through the shared runner, shows an upper bound of the cost before running, flags results whose source text changed meanwhile, and applies them as plain-text nodes in one transaction so a single Undo reverts them. Stopping or closing cancels the provider call and charges no points. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR --- apps/web/src/locales/@vitnode/core/pl.json | 32 ++ .../modules/ai/routes/stream.route.test.ts | 4 +- .../src/components/ai/ai-field-assist.tsx | 21 +- .../components/ai/use-available-ai-actions.ts | 36 ++ .../tiptap/quick-ask/quick-ask-editor.test.ts | 101 +++++ .../tiptap/quick-ask/quick-ask-editor.ts | 103 +++++ .../toolbar/actions/quick-ask-action.test.tsx | 108 +++++ .../toolbar/actions/quick-ask-action.tsx | 420 ++++++++++++++++++ .../tiptap/toolbar/tiptap-toolbar.tsx | 2 + packages/vitnode/src/lib/ai/stream-client.ts | 131 ++++++ packages/vitnode/src/locales/en.json | 32 ++ 11 files changed, 968 insertions(+), 22 deletions(-) create mode 100644 packages/vitnode/src/components/ai/use-available-ai-actions.ts create mode 100644 packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.ts create mode 100644 packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.ts create mode 100644 packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.test.tsx create mode 100644 packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.tsx create mode 100644 packages/vitnode/src/lib/ai/stream-client.ts diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 76551ef0a..e517a0af0 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -2714,6 +2714,38 @@ "AI_UNAUTHORIZED": "Nie masz dostępu do tej funkcji AI lub tej treści.", "AI_USER_LIMIT_REACHED": "Wykorzystano wszystkie punkty AI w tym miesiącu.", "unknown": "Coś poszło nie tak. Spróbuj ponownie." + }, + "quick_ask": { + "label": "Szybkie pytanie (AI)", + "writing": "Piszę…", + "stop": "Zatrzymaj", + "discard": "Odrzuć", + "insert_below": "Wstaw poniżej", + "replace": "Zastąp zaznaczenie", + "stale": "Zaznaczony tekst zmienił się w trakcie pisania odpowiedzi. Wstaw ją poniżej albo zamknij i spróbuj ponownie.", + "tone": "Zmień ton", + "select_text": "Zaznacz tekst, aby go przepisać.", + "operation": { + "shorten": "Skróć", + "correct": "Popraw pisownię", + "simplify": "Uprość", + "continue": "Pisz dalej" + }, + "tones": { + "friendly": "Przyjazny", + "formal": "Formalny", + "confident": "Pewny siebie", + "neutral": "Neutralny" + }, + "custom_label": "Albo zapytaj o cokolwiek w sprawie zaznaczenia", + "custom_placeholder": "np. Zamień to w listę punktów", + "ask": "Zapytaj", + "estimate": "Jedno żądanie kosztuje najwyżej {points} punktów AI – zwykle znacznie mniej.", + "applied": { + "title": "Dodano tekst AI", + "desc": "Nic nie jest zapisane, dopóki nie zapiszesz formularza.", + "undo": "Cofnij" + } } } }, diff --git a/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts b/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts index 0e8088cc5..125be5ca7 100644 --- a/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts +++ b/packages/vitnode/src/api/modules/ai/routes/stream.route.test.ts @@ -80,8 +80,8 @@ const createApp = ( c.set("ai", new AIModel(c as unknown as Context, { ledger })); await next(); }); - app.openapi(streamAiRoute.route, streamAiRoute.handler); - app.openapi(estimateAiRoute.route, estimateAiRoute.handler); + app.openapi(streamAiRoute.route, streamAiRoute.handler as never); + app.openapi(estimateAiRoute.route, estimateAiRoute.handler as never); return { app, ledger }; }; diff --git a/packages/vitnode/src/components/ai/ai-field-assist.tsx b/packages/vitnode/src/components/ai/ai-field-assist.tsx index c9c572881..ec2e48c9f 100644 --- a/packages/vitnode/src/components/ai/ai-field-assist.tsx +++ b/packages/vitnode/src/components/ai/ai-field-assist.tsx @@ -1,4 +1,3 @@ -import { useQuery } from "@tanstack/react-query"; import { SparklesIcon } from "lucide-react"; import React from "react"; import { toast } from "sonner"; @@ -17,7 +16,6 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Spinner } from "@/components/ui/spinner"; -import { CONFIG_PLUGIN } from "@/config"; import { aiErrorCodeOf, requestAiAssist, @@ -30,29 +28,12 @@ import { fieldTextOf, suggestionFreshness, } from "@/lib/ai/suggestion"; -import { fetcher } from "@/tanstack/fetcher"; import { useContentFormValues, useSetContentFormValue, } from "@/views/admin/views/content/form/values"; -/** The AI actions this admin may start; cached for the session. */ -export const useAvailableAiActions = () => - useQuery({ - queryFn: async () => { - const response = await fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - method: "get", - module: "admin/ai", - path: "/assist/available", - }); - if (!response.ok) return [] as string[]; - - return (await response.json()).actions; - }, - queryKey: ["ai", "assist", "available"], - staleTime: 5 * 60_000, - }); +import { useAvailableAiActions } from "./use-available-ai-actions"; /** * The AI button of a text field with `ai` assistance. It asks the field's diff --git a/packages/vitnode/src/components/ai/use-available-ai-actions.ts b/packages/vitnode/src/components/ai/use-available-ai-actions.ts new file mode 100644 index 000000000..3c3580395 --- /dev/null +++ b/packages/vitnode/src/components/ai/use-available-ai-actions.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; + +import type { AiAssistScope } from "@/lib/ai/stream-client"; + +import { CONFIG_PLUGIN } from "@/config"; +import { fetcher } from "@/tanstack/fetcher"; + +/** + * The AI actions the signed-in person may start, as the server decides it - + * enabled, a model can run them, and their roles grant them. Only decides + * which buttons to show; every run is authorized again on the server. + */ +export const useAvailableAiActions = (scope: AiAssistScope = "admin") => + useQuery({ + queryFn: async () => { + const response = + scope === "admin" + ? await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/ai", + path: "/assist/available", + }) + : await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "ai", + path: "/available", + }); + if (!response.ok) return [] as string[]; + + return (await response.json()).actions; + }, + queryKey: ["ai", "available", scope], + staleTime: 5 * 60_000, + }); diff --git a/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.ts b/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.ts new file mode 100644 index 000000000..f64fb89e1 --- /dev/null +++ b/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.ts @@ -0,0 +1,101 @@ +import { Editor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + applyQuickAskResult, + isQuickAskStale, + quickAskTextToNodes, + takeQuickAskSnapshot, +} from "./quick-ask-editor"; + +let editor: Editor | undefined; + +const createEditor = (content: string) => { + editor = new Editor({ content, extensions: [StarterKit] }); + + return editor; +}; + +afterEach(() => { + editor?.destroy(); + editor = undefined; +}); + +const selectText = (target: Editor, text: string) => { + let found = { from: 0, to: 0 }; + target.state.doc.descendants((node, position) => { + if (node.isText && node.text?.includes(text)) { + const offset = node.text.indexOf(text); + found = { from: position + offset, to: position + offset + text.length }; + } + }); + target.commands.setTextSelection(found); +}; + +describe("Quick Ask in the editor", () => { + it("sends the selection with bounded context, never the whole document", () => { + const target = createEditor( + `

    ${"a".repeat(5_000)} middle ${"b".repeat(5_000)}

    `, + ); + selectText(target, "middle"); + + const snapshot = takeQuickAskSnapshot(target); + + expect(snapshot.selection).toBe("middle"); + expect(snapshot.before).toHaveLength(1_500); + expect(snapshot.after).toHaveLength(1_500); + }); + + it("replaces the selection in one step that a single undo reverts", () => { + const target = createEditor("

    Hello brave new world

    "); + selectText(target, "brave new"); + const { from, to } = takeQuickAskSnapshot(target); + + applyQuickAskResult(target, { from, mode: "replace", text: "small", to }); + expect(target.getHTML()).toBe("

    Hello small world

    "); + + target.commands.undo(); + expect(target.getHTML()).toBe("

    Hello brave new world

    "); + }); + + it("inserts below the selection's block, leaving it untouched", () => { + const target = createEditor("

    First.

    Second.

    "); + selectText(target, "First"); + const { from, to } = takeQuickAskSnapshot(target); + + applyQuickAskResult(target, { + from, + mode: "below", + text: "Added one.\n\nAdded two.", + to, + }); + + expect(target.getHTML()).toBe( + "

    First.

    Added one.

    Added two.

    Second.

    ", + ); + }); + + it("never interprets model output as HTML", () => { + expect(quickAskTextToNodes('hi')).toEqual( + [ + { + content: [ + { text: 'hi', type: "text" }, + ], + type: "paragraph", + }, + ], + ); + }); + + it("notices when the selected text changed during generation", () => { + const target = createEditor("

    Hello brave new world

    "); + selectText(target, "brave"); + const snapshot = takeQuickAskSnapshot(target); + + expect(isQuickAskStale(target, snapshot)).toBe(false); + target.commands.insertContentAt(snapshot.from, "very "); + expect(isQuickAskStale(target, snapshot)).toBe(true); + }); +}); diff --git a/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.ts b/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.ts new file mode 100644 index 000000000..d281ebb14 --- /dev/null +++ b/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.ts @@ -0,0 +1,103 @@ +import type { Editor, JSONContent } from "@tiptap/react"; + +/** Mirrors the server's bounds (`QUICK_ASK_LIMITS`): nothing larger is sent. */ +export const QUICK_ASK_CLIENT_LIMITS = { + context: 1_500, + selection: 8_000, +} as const; + +export interface QuickAskSnapshot { + after: string; + before: string; + from: number; + selection: string; + to: number; +} + +/** + * What Quick Ask sends: the selection and a bounded slice of text around it - + * never the whole document. + */ +export const takeQuickAskSnapshot = (editor: Editor): QuickAskSnapshot => { + const { doc, selection } = editor.state; + const { from, to } = selection; + const before = doc.textBetween(0, from, "\n", " "); + const after = doc.textBetween(to, doc.content.size, "\n", " "); + + return { + after: after.slice(0, QUICK_ASK_CLIENT_LIMITS.context), + before: before.slice(-QUICK_ASK_CLIENT_LIMITS.context), + from, + selection: doc + .textBetween(from, to, "\n", " ") + .slice(0, QUICK_ASK_CLIENT_LIMITS.selection), + to, + }; +}; + +/** + * Whether the text the request was made from is still in place. A suggestion + * for text that changed - or moved - must not replace it blindly. + */ +export const isQuickAskStale = ( + editor: Editor, + snapshot: Pick, +): boolean => { + const { doc } = editor.state; + if (snapshot.to > doc.content.size) return true; + + return ( + doc.textBetween(snapshot.from, snapshot.to, "\n", " ") !== + snapshot.selection + ); +}; + +/** + * Model output as document nodes, built from plain text only: paragraphs on + * blank lines, hard breaks on single ones. The output is never parsed as + * HTML, so it cannot inject markup, links or scripts into the document. + */ +export const quickAskTextToNodes = (text: string): JSONContent[] => + text + .replace(/\r\n?/g, "\n") + .split(/\n{2,}/) + .map(paragraph => paragraph.trim()) + .filter(Boolean) + .map(paragraph => ({ + content: paragraph + .split("\n") + .flatMap((line, index): JSONContent[] => [ + ...(index > 0 ? [{ type: "hardBreak" }] : []), + ...(line ? [{ text: line, type: "text" }] : []), + ]), + type: "paragraph", + })); + +/** + * Applies a suggestion in one transaction, so a single Undo restores the + * text exactly as it was. + */ +export const applyQuickAskResult = ( + editor: Editor, + { + from, + mode, + text, + to, + }: { from: number; mode: "below" | "replace"; text: string; to: number }, +): boolean => { + const nodes = quickAskTextToNodes(text); + if (nodes.length === 0) return false; + + if (mode === "replace") { + // One paragraph replacing text inside a paragraph stays inline. + const content = nodes.length === 1 ? (nodes[0].content ?? []) : nodes; + + return editor.chain().focus().insertContentAt({ from, to }, content).run(); + } + + const $to = editor.state.doc.resolve(to); + const position = $to.depth > 0 ? $to.after(1) : to; + + return editor.chain().focus().insertContentAt(position, nodes).run(); +}; diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.test.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.test.tsx new file mode 100644 index 000000000..66a501aa6 --- /dev/null +++ b/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.test.tsx @@ -0,0 +1,108 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { describe, expect, it, vi } from "vitest"; + +import { QuickAskPanel } from "./quick-ask-action"; + +const renderPanel = ( + props: Partial[0]> = {}, +) => { + const handlers = { + onApply: vi.fn(), + onDiscard: vi.fn(), + onInstruction: vi.fn(), + onRun: vi.fn(), + onStop: vi.fn(), + }; + render( + + + , + ); + + return handlers; +}; + +const button = (name: string) => screen.getByRole("button", { name }); + +describe("QuickAskPanel", () => { + it("offers the rewrite operations and runs the one clicked", () => { + const { onRun } = renderPanel(); + + fireEvent.click(button("core.ai_assist.quick_ask.operation.shorten")); + fireEvent.click(button("core.ai_assist.quick_ask.tones.formal")); + + expect(onRun).toHaveBeenNthCalledWith(1, { + kind: "rewrite", + operation: "shorten", + }); + expect(onRun).toHaveBeenNthCalledWith(2, { + kind: "rewrite", + operation: "tone", + tone: "formal", + }); + }); + + it("shows the bound before anything runs", () => { + renderPanel({ estimate: "12.5" }); + + expect(screen.getByText("core.ai_assist.quick_ask.estimate")).toBeTruthy(); + }); + + it("sends a custom request only with an instruction", () => { + const { onRun } = renderPanel({ instruction: "Make a list" }); + + fireEvent.click(button("core.ai_assist.quick_ask.ask")); + + expect(onRun).toHaveBeenCalledWith({ + instruction: "Make a list", + kind: "custom", + }); + }); + + it("can stop a streaming answer", () => { + const { onStop } = renderPanel({ + phase: { kind: "streaming", text: "Partial" }, + }); + + expect(screen.getByText("Partial")).toBeTruthy(); + fireEvent.click(button("core.ai_assist.quick_ask.stop")); + expect(onStop).toHaveBeenCalledOnce(); + }); + + it("offers replace, insert below and discard for a result", () => { + const { onApply, onDiscard } = renderPanel({ + phase: { kind: "result", runId: 1, text: "Shorter." }, + }); + + fireEvent.click(button("core.ai_assist.quick_ask.replace")); + fireEvent.click(button("core.ai_assist.quick_ask.insert_below")); + fireEvent.click(button("core.ai_assist.quick_ask.discard")); + + expect(onApply.mock.calls).toEqual([["replace"], ["below"]]); + expect(onDiscard).toHaveBeenCalledOnce(); + }); + + it("refuses to replace text that changed during generation", () => { + renderPanel({ + phase: { kind: "result", runId: 1, text: "Shorter." }, + stale: true, + }); + + expect(screen.getByText("core.ai_assist.quick_ask.stale")).toBeTruthy(); + expect( + screen.getByRole("button", { + name: "core.ai_assist.quick_ask.replace", + }).disabled, + ).toBe(true); + }); +}); diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.tsx new file mode 100644 index 000000000..a62c64425 --- /dev/null +++ b/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.tsx @@ -0,0 +1,420 @@ +import { QueryClientContext } from "@tanstack/react-query"; +import { useEditorState } from "@tiptap/react"; +import { SparklesIcon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useLocale, useTranslations } from "use-intl"; + +import type { QuickAskSnapshot } from "@/components/tiptap/quick-ask/quick-ask-editor"; + +import { useAvailableAiActions } from "@/components/ai/use-available-ai-actions"; +import { + applyQuickAskResult, + isQuickAskStale, + takeQuickAskSnapshot, +} from "@/components/tiptap/quick-ask/quick-ask-editor"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Spinner } from "@/components/ui/spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { Toggle } from "@/components/ui/toggle"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { aiErrorCodeOf, sendAiFeedback } from "@/lib/ai/assist-client"; +import { + aiAssistScope, + estimateAiAction, + streamAiAction, +} from "@/lib/ai/stream-client"; + +import { useToolbarEditor } from "../use-toolbar-editor"; + +export const QUICK_ASK_REWRITE_ACTION = + "@vitnode/core:editor.selection.rewrite"; +export const QUICK_ASK_ACTION = "@vitnode/core:editor.quick-ask"; + +type Operation = + | { instruction: string; kind: "custom" } + | { kind: "continue" } + | { + kind: "rewrite"; + operation: "correct" | "shorten" | "simplify" | "tone"; + tone?: "confident" | "formal" | "friendly" | "neutral"; + }; + +type Phase = + | { kind: "error"; message: string } + | { kind: "idle" } + | { kind: "result"; runId: number; text: string } + | { kind: "streaming"; text: string }; + +const TONES = ["friendly", "formal", "confident", "neutral"] as const; + +/** + * Quick Ask: rewrite the selection, continue the text or follow a custom + * request - streamed, previewed, then applied only when the writer chooses. + * Applying is one editor transaction, so Undo restores the text. + */ +export const QuickAskAction = () => { + const t = useTranslations("core.ai_assist.quick_ask"); + const tError = useTranslations("core.ai_assist.error"); + const locale = useLocale(); + const { editor } = useToolbarEditor(); + const scope = aiAssistScope(); + const { data: available = [] } = useAvailableAiActions(scope); + const [open, setOpen] = React.useState(false); + const [phase, setPhase] = React.useState({ kind: "idle" }); + const [instruction, setInstruction] = React.useState(""); + const [estimate, setEstimate] = React.useState(null); + const [snapshot, setSnapshot] = React.useState(null); + const abortRef = React.useRef(null); + // Re-evaluated on every editor change while a result waits for a decision. + const stale = useEditorState({ + editor, + selector: ctx => + phase.kind === "result" && snapshot + ? isQuickAskStale(ctx.editor, snapshot) + : false, + }); + + const canRewrite = available.includes(QUICK_ASK_REWRITE_ACTION); + const canAsk = available.includes(QUICK_ASK_ACTION); + if (!canRewrite && !canAsk) return null; + + const hasSelection = !editor.state.selection.empty; + + const inputFor = (operation: Operation, snapshot: QuickAskSnapshot) => { + const context = { + after: snapshot.after, + before: snapshot.before, + locale, + selection: snapshot.selection, + }; + if (operation.kind === "rewrite") { + return { + action: QUICK_ASK_REWRITE_ACTION, + input: { + ...context, + operation: operation.operation, + tone: operation.tone, + }, + }; + } + + return { + action: QUICK_ASK_ACTION, + input: { + ...context, + instruction: + operation.kind === "custom" ? operation.instruction : undefined, + mode: operation.kind, + }, + }; + }; + + const onOpenChange = (next: boolean) => { + if (!next) { + abortRef.current?.abort(); + if (phase.kind === "result") { + void sendAiFeedback({ accepted: false, runId: phase.runId }); + } + setPhase({ kind: "idle" }); + setOpen(false); + + return; + } + const taken = takeQuickAskSnapshot(editor); + setSnapshot(taken); + setOpen(true); + setEstimate(null); + if (canRewrite && taken.selection) { + // A bound for one request, shown before anything runs. + void estimateAiAction({ + ...inputFor({ kind: "rewrite", operation: "shorten" }, taken), + scope, + }).then(setEstimate); + } + }; + + const run = async (operation: Operation) => { + const source = snapshot ?? takeQuickAskSnapshot(editor); + setSnapshot(source); + abortRef.current?.abort(); + const abort = new AbortController(); + abortRef.current = abort; + setPhase({ kind: "streaming", text: "" }); + let text = ""; + try { + const summary = await streamAiAction({ + ...inputFor(operation, source), + onDelta: delta => { + text += delta; + setPhase({ kind: "streaming", text }); + }, + scope, + signal: abort.signal, + }); + setPhase({ kind: "result", runId: summary.runId, text: text.trim() }); + } catch (error) { + if (abort.signal.aborted) { + setPhase({ kind: "idle" }); + + return; + } + setPhase({ kind: "error", message: tError(aiErrorCodeOf(error)) }); + } + }; + + const apply = (mode: "below" | "replace") => { + if (phase.kind !== "result" || !snapshot) return; + const applied = applyQuickAskResult(editor, { + from: snapshot.from, + mode, + text: phase.text, + to: snapshot.to, + }); + if (!applied) return; + void sendAiFeedback({ accepted: true, runId: phase.runId }); + setPhase({ kind: "idle" }); + setOpen(false); + toast.success(t("applied.title"), { + action: { + label: t("applied.undo"), + onClick: () => { + editor.commands.undo(); + }, + }, + description: t("applied.desc"), + }); + }; + + return ( + + + + } + > + + + + + + { + onOpenChange(false); + }} + onInstruction={setInstruction} + onRun={operation => void run(operation)} + onStop={() => { + abortRef.current?.abort(); + }} + phase={phase} + stale={stale} + /> + + + ); +}; + +/** + * Quick Ask needs the app's query client to learn which AI actions the + * person may use; an editor rendered without one simply has no AI button. + */ +export const QuickAskSlot = () => + React.use(QueryClientContext) ? : null; + +/** The panel's content for each phase - separate so it can be tested by DOM. */ +export const QuickAskPanel = ({ + canAsk, + canRewrite, + estimate, + instruction, + onApply, + onDiscard, + onInstruction, + onRun, + onStop, + phase, + stale, +}: { + canAsk: boolean; + canRewrite: boolean; + estimate: null | string; + instruction: string; + onApply: (mode: "below" | "replace") => void; + onDiscard: () => void; + onInstruction: (value: string) => void; + onRun: (operation: Operation) => void; + onStop: () => void; + phase: Phase; + stale: boolean; +}) => { + const t = useTranslations("core.ai_assist.quick_ask"); + const instructionId = React.useId(); + + if (phase.kind === "streaming") { + return ( +
    +

    + {phase.text || ( + + {t("writing")} + + )} +

    + +
    + ); + } + + if (phase.kind === "result") { + return ( +
    +

    + {phase.text} +

    + {stale ? ( +

    + {t("stale")} +

    + ) : null} +
    + + + +
    +
    + ); + } + + return ( +
    + {phase.kind === "error" ? ( +

    + {phase.message} +

    + ) : null} + + {canRewrite ? ( +
    +
    + {(["shorten", "correct", "simplify"] as const).map(operation => ( + + ))} +
    +

    + {t("tone")} +

    +
    + {TONES.map(tone => ( + + ))} +
    +
    + ) : ( +

    + {t("select_text")} +

    + )} + + {canAsk ? ( +
    + + +