From b917a38bea00e540ec9ec22d9d4fa4153267eeea Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 13 Nov 2026 21:22:12 +0800 Subject: [PATCH 1/6] Make AI work again on hosted Void Cloud: publish AI logging policy to KV Since 1.8.0 the proxy asked the API for each project's AI logging policy on every AI call, and the API refused unless PLATFORM_AUTH_DATABASE_UNCACHED was set. Hosted Void Cloud never sets it, so every hosted AI call failed with 503 ai_logging_policy_unavailable. Now the API and the installer write the policy to ROUTING KV key `ai-logging:`, and the proxy reads it from there. Shared (`@void/platform/ai-logging`) - Key names, value and metadata shape, parsers, resolver and the 30 s read cache time, used by the API, the installer and the proxy. Proxy - Reads the key (cacheTtl 30) in /ai/run, /ai/provider and /ai/toMarkdown, after body validation. Missing key: inherit (no logging option, as in 1.7.0). off / metadata / full: the 1.8.0 gateway mapping. KV error: 503 ai_logging_policy_unavailable. Bad value: 503 ai_logging_policy_invalid. Caller-supplied logging keys are still stripped. - Adds a manual live-check script for the two Cloudflare behaviours the mapping depends on. API - publishProjectAiLogging locks the project row, reads every input through a write statement (cached Hyperdrive reads never feed a publish), and writes KV before the lock is released. - The operator POST orders the write by direction: a tighten writes in the same transaction; a loosen or inherit commits first and publishes in a second locked step. Errors: ai_logging_publish_failed / _pending. - Plan defaults in platform_settings 'plans:ai-logging', published in pages of 100. A user plan change publishes one page per call; transfer, create and delete keep the key in step. - Readback returns saved, plan, effective, source, published and a state: applied, pending, pending_plan_publish, drift or unknown. - An hourly sweep repairs drifted keys within a 400 KV operation budget. - The internal route 1.8.x proxies call is kept and answers from KV. Installer and CLI - Every install, upgrade and repair copies each project's setting from Postgres to KV, before and after the Worker upload, under the same row locks as the API. - `void platform project ai-logging` and `project show` print the source, effective mode and state; plan files accept `aiLogging`. Docs - CLI reference, admin guides, upgrade note, design doc 0094, operations notes for the hosted rollout. Refs #793. Co-Authored-By: Claude Opus 5.5 --- docs/guide/ai.md | 2 +- docs/guide/platform/administration/plans.md | 35 +- .../guide/platform/administration/projects.md | 8 +- .../platform/installation/maintenance.md | 4 + docs/reference/cli.md | 2 +- docs/reference/cli/platform-config.md | 6 +- docs/reference/cli/platform-users.md | 69 +- packages/platform/package.json | 4 + packages/platform/src/ai-logging.ts | 288 ++++++ .../platform/test/unit/ai-logging.test.ts | 182 ++++ packages/platform/vite.config.ts | 1 + packages/void/src/cli/client.ts | 10 +- packages/void/src/cli/help.ts | 9 +- packages/void/src/cli/operator-ai-logging.ts | 169 +++ packages/void/src/cli/operator-args.ts | 6 +- packages/void/src/cli/operator-cmd.ts | 38 +- .../src/cli/platform-ai-logging-projection.ts | 249 +++++ packages/void/src/cli/platform-cloudflare.ts | 92 ++ .../src/cli/platform-install-provisioning.ts | 13 + .../src/cli/platform-installer-database.ts | 3 + .../void/src/cli/platform-installer-types.ts | 15 + packages/void/src/cli/platform-installer.ts | 10 + packages/void/src/cli/platform-management.ts | 1 + .../void/src/cli/platform-plans-config.ts | 225 +++- packages/void/test/unit/operator-cli.test.ts | 205 ++++ .../platform-ai-logging-projection.test.ts | 373 +++++++ .../test/unit/platform-cloudflare.test.ts | 83 ++ .../platform-deployment-execution.test.ts | 3 + .../unit/platform-install-cutover.test.ts | 18 + .../void/test/unit/platform-installer.test.ts | 117 +++ .../unit/platform-management-upgrade.test.ts | 4 + .../test/unit/platform-plans-config.test.ts | 225 ++++ platform/meta/design-docs/0019-workers-ai.md | 2 +- .../design-docs/0049-multi-provider-ai.md | 2 +- .../0094-ai-gateway-logging-policy.md | 380 +++++++ platform/meta/design-docs/README.md | 1 + platform/meta/operations.md | 12 + platform/packages/api/package.json | 3 +- .../packages/api/src/admin/fate/page-data.ts | 29 +- .../api/src/admin/fate/page-server.ts | 3 + platform/packages/api/src/admin/fate/views.ts | 3 + platform/packages/api/src/admin/plans.ts | 11 + .../api/src/admin/react/views/PlansPage.tsx | 84 +- .../admin/react/views/ProjectDetailPage.tsx | 7 +- .../src/admin/react/views/UserDetailPage.tsx | 11 +- .../api/src/admin/react/views/format.ts | 53 + .../packages/api/src/admin/routes/plans.ts | 27 +- platform/packages/api/src/ai-logging-plans.ts | 562 ++++++++++ .../packages/api/src/ai-logging-policy.ts | 37 +- platform/packages/api/src/ai-logging-sweep.ts | 228 +++++ platform/packages/api/src/ai-logging.ts | 577 ++++++++++- platform/packages/api/src/auth/oauth.ts | 2 +- platform/packages/api/src/db/users.ts | 22 +- platform/packages/api/src/index.ts | 8 + platform/packages/api/src/operator/audit.ts | 2 + .../packages/api/src/operator/plans-routes.ts | 13 +- platform/packages/api/src/operator/plans.ts | 191 +++- .../api/src/operator/tenant-routes.ts | 54 +- .../api/src/operator/tenant-service.ts | 86 +- platform/packages/api/src/plans.ts | 38 +- .../packages/api/src/provisioning/cleanup.ts | 11 +- .../api/src/routes/internal-ai-logging.ts | 39 +- platform/packages/api/src/routes/previews.ts | 23 + platform/packages/api/src/routes/projects.ts | 24 +- .../test/integration/admin-dashboard.test.ts | 34 + .../api/test/integration/admin-plans.test.ts | 153 ++- .../api/test/integration/api-delete.test.ts | 37 + .../packages/api/test/integration/helpers.ts | 42 +- .../api/test/integration/oauth.test.ts | 41 + .../integration/operator-ai-logging.test.ts | 758 ++++++++++++++ .../test/integration/operator-cli-e2e.test.ts | 35 +- .../test/integration/operator-tenants.test.ts | 7 +- .../test/integration/plan-ai-logging.test.ts | 967 ++++++++++++++++++ .../api/test/integration/scheduled.test.ts | 47 +- .../api/test/unit/ai-logging-plans.test.ts | 141 +++ .../api/test/unit/ai-logging-state.test.ts | 95 ++ .../api/test/unit/hosted-config.test.ts | 30 +- .../api/test/unit/operator-audit.test.ts | 15 + platform/packages/proxy/README.md | 18 +- platform/packages/proxy/src/ai-logging.ts | 122 ++- platform/packages/proxy/src/ai.ts | 55 +- platform/packages/proxy/src/types.ts | 4 +- .../packages/proxy/test/unit/proxy.test.ts | 332 ++++-- platform/scripts/ai-logging-live-check.ts | 309 ++++++ 84 files changed, 7867 insertions(+), 389 deletions(-) create mode 100644 packages/platform/src/ai-logging.ts create mode 100644 packages/platform/test/unit/ai-logging.test.ts create mode 100644 packages/void/src/cli/operator-ai-logging.ts create mode 100644 packages/void/src/cli/platform-ai-logging-projection.ts create mode 100644 packages/void/test/unit/platform-ai-logging-projection.test.ts create mode 100644 platform/meta/design-docs/0094-ai-gateway-logging-policy.md create mode 100644 platform/packages/api/src/ai-logging-plans.ts create mode 100644 platform/packages/api/src/ai-logging-sweep.ts create mode 100644 platform/packages/api/test/integration/operator-ai-logging.test.ts create mode 100644 platform/packages/api/test/integration/plan-ai-logging.test.ts create mode 100644 platform/packages/api/test/unit/ai-logging-plans.test.ts create mode 100644 platform/packages/api/test/unit/ai-logging-state.test.ts create mode 100644 platform/scripts/ai-logging-live-check.ts diff --git a/docs/guide/ai.md b/docs/guide/ai.md index d24efbe43..56719d925 100644 --- a/docs/guide/ai.md +++ b/docs/guide/ai.md @@ -142,7 +142,7 @@ const result = await ai.run('google/gemini-2.5-flash', { }); ``` -Pass AI Gateway options as the third argument: `ai.run(model, inputs, { gateway: { skipCache: true } })`. On a team platform, the platform chooses the gateway and sets its metadata and logging; options such as `skipCache` and `cacheTtl` still apply. +Pass AI Gateway options as the third argument: `ai.run(model, inputs, { gateway: { skipCache: true } })`. On a team platform, the platform chooses the gateway and sets its metadata and logging; options such as `skipCache` and `cacheTtl` still apply. The platform's logging setting also covers `void dev` in a linked project; an unlinked project uses your own Cloudflare account and its gateway settings. For provider-native requests on a direct deployment, configure [`ai.gateway`](../integrations/cloudflare.md#ai-self-host). diff --git a/docs/guide/platform/administration/plans.md b/docs/guide/platform/administration/plans.md index 57976c654..9a499288a 100644 --- a/docs/guide/platform/administration/plans.md +++ b/docs/guide/platform/administration/plans.md @@ -16,7 +16,7 @@ Choose a plan to inspect or edit it, copy one into a new plan, or choose the def ## Use the Dashboard -Open **Plans** to see each plan's stable ID, account count, and signup default. Choose **Add plan** to copy an existing plan, or **Edit** to change its display name, limits, build size, and permission to use short project slugs. +Open **Plans** to see each plan's stable ID, account count, and signup default. Choose **Add plan** to copy an existing plan, or **Edit** to change its display name, limits, build size, permission to use short project slugs, and [AI Gateway logging default](#ai-gateway-logging). Choose **Review changes** to compare the current and proposed settings and see how many accounts are affected. Review any quota warnings, then choose **Apply changes**. If someone changed the catalog while you were reviewing, open the plan again and create a fresh review. @@ -89,6 +89,39 @@ Storage figures shown for D1, R2, and KV are informational and cannot be edited The preview shows the affected account count and checks a sample for accounts already above a proposed quota. Lowering a quota can restrict existing apps, including accounts outside that sample. Usage history and billing periods are preserved, so a plan change does not reset usage. Manual administrator suspensions remain in effect. +## AI Gateway Logging {#ai-gateway-logging} + +Each plan can set how AI Gateway logs AI requests for its accounts' projects: `inherit`, `off`, `metadata`, or `full`. See [AI Gateway logging](../../../reference/cli/platform-users.md#ai-gateway-logging) for what each mode stores. Plans start with `inherit`, which follows the gateway's own setting. + +A project's own setting from `void platform project ai-logging` always wins over the plan default. Accounts whose plan is not in the catalog use the signup default plan's setting. + +In the dashboard, choose **Edit** on a plan and pick its **AI Gateway logging default**. In a plan file, set `aiLogging`: + +```json +{ + "aiLogging": "metadata" +} +``` + +```sh +void platform config plans set team --file team-plan.json --plan +void platform config plans set team --file team-plan.json --yes +``` + +`"aiLogging": "inherit"` clears the plan default. Leaving `aiLogging` out keeps the current value. The interactive menu also offers **AI Gateway logging**. The preview shows the change, such as `AI Gateway logging: inherit → metadata`, and how many projects will receive it. + +After applying, the CLI publishes the default to every project it affects, then prints: + +```txt +AI logging published to 120 projects; expected by 14:03:20 UTC (normally within 2 minutes). +``` + +A plan default applies to a project once the command prints this line, then normally within 2 minutes. Until then, projects keep their previous behavior and `void platform project show ` reports `pending_plan_publish`. If publishing stops, the command exits with a nonzero status. Continue it with `void platform config plans sync --yes`, or choose **Continue AI logging publish** on the **Plans** page. The platform also continues it every hour. + +Copying a plan copies its AI logging default. Changing the signup default also changes the setting for accounts whose plan is not in the catalog. When you remove a plan with a replacement, its accounts use the replacement plan's default. Moving a user to another plan, or transferring a project to a new owner, applies the new plan's default. + +`void platform config plans show ` and the user detail page in the dashboard show the plan's AI Gateway logging default. Plan defaults need a platform release that supports them. On an older platform, the CLI stops and asks you to run `void platform upgrade` first. + ## Archive or Remove a Plan Archive a plan to stop assigning it to new accounts while preserving its current accounts: diff --git a/docs/guide/platform/administration/projects.md b/docs/guide/platform/administration/projects.md index 29868b94c..aba17477e 100644 --- a/docs/guide/platform/administration/projects.md +++ b/docs/guide/platform/administration/projects.md @@ -34,9 +34,11 @@ Keep prompt and response bodies out of a project's AI Gateway logs while retaini void platform project ai-logging metadata ``` -Only platform administrators can change this setting. It covers the project's AI requests through the platform, including calls with the app's own provider key. +Only platform administrators can change this setting. It covers the project's AI requests through the platform, including calls with the app's own provider key and calls from `void dev`. -Use `inherit` for the gateway default, `off` to stop log collection, or `full` to include bodies. Read the saved mode with `void platform project show --json`. See [AI Gateway logging](../../../reference/cli/platform-users.md#ai-gateway-logging) for billing and Logpush details. +Use `inherit` for the gateway's own setting, `off` to store no log entry, or `full` to include bodies. A project's own setting wins over its plan's [AI Gateway logging default](./plans.md#ai-gateway-logging); `inherit` removes the project's own setting. + +The command prints when new requests are expected to follow the change, normally within 2 minutes. Check it with `void platform project show `, which reports a line such as `AI Gateway logging: metadata (from plan "pro") — applied`. The project page in the admin dashboard shows the same line. See [AI Gateway logging](../../../reference/cli/platform-users.md#ai-gateway-logging) for each state, what the setting covers, and billing and Logpush details. ### Transferring Project Ownership @@ -49,7 +51,7 @@ void platform project owner --yes The preview shows both owners, their plans and suspension state, blockers, and the changes to routing and usage counters. Wait for active rollbacks and builds to finish. For an ordinary active deployment, wait or use `void platform deployment cancel `; rollback deployments cannot be canceled. A managed Sandbox controller also blocks transfer until its cleanup completes, even if no Sandbox container is running; the preview identifies the deployment that owns it. -After the transfer, the former owner becomes a project administrator. Existing project-scoped CI deploy credentials are revoked; create replacements as the new owner. The new owner's plan and limits apply immediately. Usage before the transfer remains charged to the former owner; later usage is charged to the new owner. If an apply reports partial convergence, inspect the project and operator event log before repeating it. +After the transfer, the former owner becomes a project administrator. Existing project-scoped CI deploy credentials are revoked; create replacements as the new owner. The new owner's plan and limits apply immediately. Its AI Gateway logging default applies to new AI requests, normally within 2 minutes, unless the project has its own setting. Usage before the transfer remains charged to the former owner; later usage is charged to the new owner. If an apply reports partial convergence, inspect the project and operator event log before repeating it. By default, new users on a self-hosted installation start with the `custom` profile, which does not cap application requests, AI usage, deployment frequency, or retained diff --git a/docs/guide/platform/installation/maintenance.md b/docs/guide/platform/installation/maintenance.md index 994709a01..aeda34ea4 100644 --- a/docs/guide/platform/installation/maintenance.md +++ b/docs/guide/platform/installation/maintenance.md @@ -114,6 +114,8 @@ void platform upgrade [id] An upgrade validates the runtime, applies supported pending migrations, deploys the Workers, and checks their health. Existing Worker secrets are preserved. +Install, upgrade, and repair also publish the saved [AI Gateway logging](/reference/cli/platform-users#ai-gateway-logging) settings (**Publishing AI logging settings**). For about 2 minutes afterward, `void platform project show` reports projects with a setting other than `inherit` as `pending`, as well as projects it switched back to `inherit`. + Before starting, Void checks access to the installation's Cloudflare account, Workers, and existing R2 buckets. If credentials are rejected, set `CLOUDFLARE_API_TOKEN` to a management token scoped to that account and retry. This overrides the installation's saved management token. Some upgrades pause application traffic until the new version passes health checks. If a command fails while traffic is paused: @@ -144,6 +146,8 @@ Keep the earlier runtime files if you need rollback. If the installed version is Do not roll back to 0.26.0 or earlier after upgrading to a later release. Void does not refuse this rollback, but those releases reject the credentials that later releases give your apps. Every project deployed or rotated since the upgrade loses AI, email, ISR revalidation, remote D1, KV, and R2, and prerendering until you upgrade again. If you have rolled back that far, upgrade again, then run `void platform system rotate-proxy-tokens --all --yes` and check with `--plan` until nothing is pending or failed. +After a rollback to 1.8.0, plan [AI Gateway logging](/guide/platform/administration/plans#ai-gateway-logging) defaults are not applied: projects that follow a plan default use the gateway's own setting, which logs prompts and responses on a new installation. Project settings still apply. Plan defaults stay saved, and the next upgrade publishes them again. A rollback to a release before 1.8.0 applies no AI logging settings. + Let running rotations finish before you roll back. If an older proxy runs next to a newer API, prerendering and remote D1, KV, and R2 fail until both run the same release. After a rollback to the release just before credential rotation, projects deployed since the upgrade lose prerendering and remote D1, KV, and R2 until they are deployed again or you upgrade again. A rotated project deployed during any rollback stays broken after you upgrade again, and 0.26.0 and earlier also drop rotations that have not finished. After upgrading again from any rollback, run the same `rotate-proxy-tokens` commands. ::: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c8f14eaa0..129582f0f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -34,7 +34,7 @@ Use this page as a command reference. If you are setting up a project for the fi ## Cheat Sheet {#cheat-sheet} -Platform administrators can also [choose AI Gateway logging per project](./cli/platform-users.md#ai-gateway-logging) with `void platform project ai-logging`. +Platform administrators can also choose AI Gateway logging [per project](./cli/platform-users.md#ai-gateway-logging) with `void platform project ai-logging`, or [per plan](../guide/platform/administration/plans.md#ai-gateway-logging) with `void platform config plans`. | Command | Purpose | | --------------------------------- | ----------------------------------------------------------------------------- | diff --git a/docs/reference/cli/platform-config.md b/docs/reference/cli/platform-config.md index 4186d400c..121b54aa2 100644 --- a/docs/reference/cli/platform-config.md +++ b/docs/reference/cli/platform-config.md @@ -21,11 +21,11 @@ void platform config plans default void platform config plans sync ``` -Use `--plan` to preview changes and affected accounts; scripted mutations require `--yes`. Every command supports `--connection ` and `--json`. `show` reports the effective limits, default, assignments, and pending account updates. `reset` restores a built-in plan's original limits and build instance size. Archived plans keep their existing accounts but cannot receive new assignments; change the default before archiving it. `remove` requires a replacement for a plan that has accounts or is the default. Changing the default affects only new accounts. +Use `--plan` to preview changes and affected accounts; scripted mutations require `--yes`. Every command supports `--connection ` and `--json`. `show` reports the effective limits, default, assignments, and pending account and AI logging updates. `show ` also reports the plan's AI Gateway logging default. `reset` restores a built-in plan's original limits and build instance size. Archived plans keep their existing accounts but cannot receive new assignments; change the default before archiving it. `remove` requires a replacement for a plan that has accounts or is the default. Changing the default affects new accounts, plus the AI Gateway logging of accounts whose plan is not in the catalog. -For `add` and `set`, `--file` reads a JSON object with optional `name`, `limits`, `buildInstanceType`, and boolean `allowShortSlugs`. `limits` is a partial object of nonnegative whole numbers; `0` means unlimited by the plan. Build timeouts remain capped at the platform maximum of 60 minutes; `0` uses that maximum. Omitted settings keep their current values or the copied plan's values. `buildInstanceType` is `standard-3` or `standard-4`. `allowShortSlugs` permits future assignments of project slugs of five characters or fewer; existing URLs remain available. Storage quotas are not editable. See [Plans and Limits](../../guide/platform/administration/plans.md) for supported limits and examples. +For `add` and `set`, `--file` reads a JSON object with optional `name`, `limits`, `buildInstanceType`, and boolean `allowShortSlugs`. `limits` is a partial object of nonnegative whole numbers; `0` means unlimited by the plan. Build timeouts remain capped at the platform maximum of 60 minutes; `0` uses that maximum. Omitted settings keep their current values or the copied plan's values. `buildInstanceType` is `standard-3` or `standard-4`. `allowShortSlugs` permits future assignments of project slugs of five characters or fewer; existing URLs remain available. `aiLogging` sets the plan's [AI Gateway logging](./platform-users.md#ai-gateway-logging) default: `off`, `metadata`, or `full`. `"inherit"` clears it; leaving it out keeps the current value. Storage quotas are not editable. See [Plans and Limits](../../guide/platform/administration/plans.md) for supported limits and examples. -An apply checks the preview's revision and rejects concurrent changes. Lower quotas can restrict accounts already above them; usage history and billing periods are preserved. The CLI continues account updates until complete. If an update fails or stops making progress, the configuration remains saved and the command exits unsuccessfully. Run `sync --yes` to continue before editing plans again. Inspect `show` and operator events after an uncertain request outcome. +An apply checks the preview's revision and rejects concurrent changes. Lower quotas can restrict accounts already above them; usage history and billing periods are preserved. The CLI continues account updates and AI logging publishing until complete, then prints how many projects received the AI logging default and when new requests are expected to follow it. If an update fails or stops making progress, the configuration remains saved and the command exits unsuccessfully. Run `sync --yes` to continue before editing plans again. Inspect `show` and operator events after an uncertain request outcome. ## Authentication configuration {#authentication-configuration} diff --git a/docs/reference/cli/platform-users.md b/docs/reference/cli/platform-users.md index 6018df568..208953d0d 100644 --- a/docs/reference/cli/platform-users.md +++ b/docs/reference/cli/platform-users.md @@ -23,7 +23,7 @@ void platform user restore void platform user delete ``` -Suspending a user blocks their applications, including scheduled jobs and queue consumers. While the account is suspended, each queue message is retried about once an hour, and each retry uses one of its queue's retries. Messages that run out of retries are dropped: after about 3 hours with the default of 3 retries, and at once for a queue with `maxRetries: 0`. Restore an account suspended by mistake promptly; queue delivery resumes within about an hour. Deleting a user also deletes their project resources. A plan change updates resource limits while preserving any administrator suspension. +Suspending a user blocks their applications, including scheduled jobs and queue consumers. While the account is suspended, each queue message is retried about once an hour, and each retry uses one of its queue's retries. Messages that run out of retries are dropped: after about 3 hours with the default of 3 retries, and at once for a queue with `maxRetries: 0`. Restore an account suspended by mistake promptly; queue delivery resumes within about an hour. Deleting a user also deletes their project resources. A plan change updates resource limits while preserving any administrator suspension. It also applies the new plan's [AI Gateway logging](#ai-gateway-logging) default to the account's projects, up to 100 projects per run. If the account has more, or if that step fails, the command says so; run the same plan change again until it completes. Each run continues where the last one stopped. The last active administrator cannot be deleted or suspended. Both previews and actual mutations enforce this rule, including in the browser admin UI. Allowed @@ -51,24 +51,71 @@ If deployment reports that a project requires normalization, an operator can pre ### AI Gateway Logging -Administrators can choose how AI Gateway logs a project's requests: +Administrators choose how AI Gateway logs a project's AI requests. Apps cannot change this setting. -| Mode | Behavior | -| ---------- | --------------------------------------------------------------- | -| `inherit` | Use the installation gateway's settings. This is the default. | -| `off` | Disable gateway log collection for this project's requests. | -| `metadata` | Collect gateway logs without storing prompt or response bodies. | -| `full` | Collect gateway logs, including prompt and response bodies. | +| Mode | AI Gateway stores | +| ---------- | ------------------------------------------------------------------------------------- | +| `inherit` | Whatever the gateway is set to. This is the default. | +| `off` | No log entry. | +| `metadata` | Model, provider, status, token counts, cost, and duration, but no prompt or response. | +| `full` | A log entry with the prompt and response. | + +`inherit` follows the installation gateway's own logging setting, which the platform does not read. A new installation creates its gateway with logging turned on, so `inherit` stores prompts and responses until you change the gateway in Cloudflare. + +Each project uses the first setting that applies: + +1. The project's own setting, from `void platform project ai-logging`. +2. The default of the owner's plan. See [Plans and Limits](../../guide/platform/administration/plans.md#ai-gateway-logging). An account whose plan is not in the catalog uses the default plan's setting. +3. `inherit`. + +Preview a change, then apply it: ```sh void platform project ai-logging metadata --plan void platform project ai-logging metadata --yes -void platform project show --json ``` -`show` includes the saved `aiLogging.mode`. Changes apply to new requests without redeploying, including Workers AI, Markdown conversion, and provider calls through the platform. An app cannot override the policy. If the platform cannot verify it, the AI request is refused. +The preview shows the current and new setting. The result names the setting, where it comes from, and when new requests are expected to follow it: + +```txt +AI logging for : metadata (set on project; plan "pro" default: inherit) +New AI requests are expected to follow it by 14:03:20 UTC (normally within 2 minutes). +``` + +Set the project back to `inherit` to remove its own setting and use the plan default again. Changes apply to new requests without redeploying. + +`void platform project show ` reports the setting and whether it is applied: + +```txt +AI Gateway logging: metadata (from plan "pro") — applied +``` + +| `state` | Meaning | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `applied` | New AI requests follow the setting. | +| `pending` | The setting is published. New requests are expected to follow it by the time shown. | +| `pending_plan_publish` | The plan default is still being published. Run `void platform config plans sync --yes`, or repeat the account's `user plan` change, to continue. | +| `drift` | The published setting differs from the saved one. An hourly check repairs it; to fix a project setting now, run `project ai-logging` again. | +| `unknown` | The published setting could not be read. Check again shortly. | + +`show` prints the state in words, such as `pending (expected by 14:03:20 UTC)`. With `--json`, `aiLogging` holds `mode` (the project's own setting, or `inherit`), `planId`, `planKnown`, `defaultPlanId`, `planMode`, `effective`, `source` (`project`, `plan`, or `gateway`), `published`, `publishedAt`, and `state`. + +#### What the Setting Covers + +When an administrator sets a project's AI logging to `off` or `metadata`, the command prints the time by which new requests are expected to follow it (normally within 2 minutes). `void platform project show ` reports `applied` once that time has passed and the setting is published. From then on, for every new AI request the platform runs for that project (Workers AI calls, Markdown conversion and provider calls, from deployed Workers and from `void dev`), AI Gateway stores: + +- `off`: no log entry. +- `metadata`: model, provider, status, token counts, cost and duration, but no prompt or response. + +A plan default applies to a project once the plan change reports "AI logging published", then normally within 2 minutes. + +If the platform cannot read the published setting, it refuses the AI request with status 503. `ai_logging_policy_unavailable` is temporary; the app can retry. `ai_logging_policy_invalid` means the published value is not valid; set the project's AI logging again. Until a change is published, the project keeps its previous behavior. `show` reports this as `pending_plan_publish` (a plan default not yet published to the project) or `drift` (a published setting that differs from the saved one). `pending` means the change is already published and is still reaching every location. On a new installation the previous behavior is full logging. + +Not covered: requests already running, logs stored earlier, separately configured Logpush exports, billing details that Cloudflare always keeps for Unified Billing requests, and log retention, which Cloudflare sets per gateway. AI usage limits still apply in every mode. See [Cloudflare logging controls](https://developers.cloudflare.com/ai-gateway/observability/logging/). + +If the command fails with `ai_logging_publish_failed`, this change was not saved; the message names the project setting that is still saved. With `ai_logging_publish_pending`, the setting was saved but not published, and the project keeps its previous behavior until it is. In both cases, run the same command again. A saved setting that is not published yet goes live at the next hourly repair even if you do not retry, so to cancel a pending change, set the previous value again. -AI usage limits still apply. `metadata` keeps request and metering details without bodies. Cloudflare may keep unified billing details even with `off`, and separately configured Logpush exports aren't changed. See [Cloudflare logging controls](https://developers.cloudflare.com/ai-gateway/observability/logging/). +Project settings need platform 1.8.0 or later. On an older platform, the CLI stops with "This platform does not support AI logging policies. Run `void platform upgrade`." ## Signup Access {#operator-signup} diff --git a/packages/platform/package.json b/packages/platform/package.json index 4d4b6c1bd..e3d86991d 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -33,6 +33,10 @@ "types": "./dist/plans.d.mts", "import": "./dist/plans.mjs" }, + "./ai-logging": { + "types": "./dist/ai-logging.d.mts", + "import": "./dist/ai-logging.mjs" + }, "./browser-page": { "types": "./dist/browser-page.d.mts", "import": "./dist/browser-page.mjs" diff --git a/packages/platform/src/ai-logging.ts b/packages/platform/src/ai-logging.ts new file mode 100644 index 000000000..1dcd31396 --- /dev/null +++ b/packages/platform/src/ai-logging.ts @@ -0,0 +1,288 @@ +/** + * AI Gateway logging policy shared by the platform API, proxy and installer. + * + * The API saves a project override (and plan defaults) in Postgres and publishes the + * effective mode to ROUTING KV under `aiLoggingKey(projectId)`. The proxy reads only + * that key. A missing key means `inherit`: the gateway's own `collect_logs` setting. + */ + +/** Every mode an operator can choose. `inherit` clears an override. */ +export const AI_LOGGING_MODES = ['inherit', 'off', 'metadata', 'full'] as const; +export type AiLoggingMode = (typeof AI_LOGGING_MODES)[number]; + +/** Modes that are saved and published. `inherit` is never stored: no row, no key. */ +export const PUBLISHED_AI_LOGGING_MODES = ['off', 'metadata', 'full'] as const; +export type PublishedAiLoggingMode = (typeof PUBLISHED_AI_LOGGING_MODES)[number]; + +/** Where an effective mode comes from. `gateway` means nothing is set (inherit). */ +export type AiLoggingSource = 'project' | 'plan' | 'gateway'; + +/** + * How a change between two effective modes moves logging. Only + * `off < metadata < full` has a known order: `inherit` follows the gateway setting, + * which the platform never reads, so any change to or from it is `unknown`. + */ +export type AiLoggingPublishOrder = 'same' | 'tighten' | 'loosen' | 'unknown'; + +const AI_LOGGING_KEY_PREFIX = 'ai-logging:'; + +export function isAiLoggingMode(value: unknown): value is AiLoggingMode { + return AI_LOGGING_MODES.some((mode) => mode === value); +} + +export function isPublishedAiLoggingMode(value: unknown): value is PublishedAiLoggingMode { + return PUBLISHED_AI_LOGGING_MODES.some((mode) => mode === value); +} + +/** ROUTING KV key that holds a project's published AI logging mode. */ +export function aiLoggingKey(projectId: string): string { + return `${AI_LOGGING_KEY_PREFIX}${projectId}`; +} + +/** + * Reads a published KV value: a missing key is `inherit`, a stored mode is that mode. + * Anything else, including a literal `inherit` or a mode from a newer platform, + * returns `undefined` so callers fail closed instead of logging more than intended. + */ +export function parsePublishedAiLogging(value: string | null): AiLoggingMode | undefined { + if (value === null) { + return 'inherit'; + } + return isPublishedAiLoggingMode(value) ? value : undefined; +} + +/** Effective mode: the project override, else the plan default, else inherit. */ +export function resolveAiLogging( + projectMode: AiLoggingMode | null | undefined, + planMode: AiLoggingMode | null | undefined, +): Readonly<{ mode: AiLoggingMode; source: AiLoggingSource }> { + if (projectMode && projectMode !== 'inherit') { + return { mode: projectMode, source: 'project' }; + } + if (planMode && planMode !== 'inherit') { + return { mode: planMode, source: 'plan' }; + } + return { mode: 'inherit', source: 'gateway' }; +} + +const LOGGING_RANK: Readonly> = { + off: 0, + metadata: 1, + full: 2, +}; + +/** Compares two effective modes (not saved project fields) to order a publish. */ +export function publishOrder( + previousEffective: AiLoggingMode, + nextEffective: AiLoggingMode, +): AiLoggingPublishOrder { + if (previousEffective === nextEffective) { + return 'same'; + } + if (previousEffective === 'inherit' || nextEffective === 'inherit') { + return 'unknown'; + } + return LOGGING_RANK[nextEffective] < LOGGING_RANK[previousEffective] ? 'tighten' : 'loosen'; +} + +/** + * How long after a KV write new requests are expected to follow it. KV may take up to + * 60 s (plus the proxy's 30 s `cacheTtl`) to reach other locations. A target, not a + * guarantee. + */ +export const AI_LOGGING_SETTLE_MS = 120_000; + +/** Edge cache time of the proxy's key read: KV's minimum, the same as the project token floor. */ +export const AI_LOGGING_CACHE_TTL_SECONDS = 30; + +const AI_LOGGING_CLEARED_KEY_PREFIX = 'ai-logging-cleared:'; + +/** + * ROUTING KV marker written just before `aiLoggingKey(projectId)` is removed for a + * project that now inherits. A removed key leaves no publish time behind, so readback + * reads this marker to report `pending` while other locations may still serve the old + * value. Only the API reads it (never the proxy), and it expires after the settle time. + * Its prefix does not start with the published key prefix, so key listings skip it. + */ +export function aiLoggingClearedKey(projectId: string): string { + return `${AI_LOGGING_CLEARED_KEY_PREFIX}${projectId}`; +} + +/** KV expiry of the cleared marker, in seconds (KV's minimum is 60). */ +export const AI_LOGGING_CLEARED_TTL_SECONDS: number = AI_LOGGING_SETTLE_MS / 1000; + +/** The KV value and options of a cleared marker written at `at` (ms). */ +export function aiLoggingClearedMarker(at: number): Readonly<{ + value: string; + metadata: Readonly<{ at: number }>; + expirationTtl: number; +}> { + return { value: 'inherit', metadata: { at }, expirationTtl: AI_LOGGING_CLEARED_TTL_SECONDS }; +} + +/** Reads a cleared marker's time; anything missing or malformed is `null`. */ +export function parseAiLoggingClearedAt(metadata: unknown): number | null { + if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) { + return null; + } + const { at } = metadata as Record; + return typeof at === 'number' && Number.isSafeInteger(at) ? at : null; +} + +/** ROUTING KV metadata written with every published key. `at` is the publish time in ms. */ +export type AiLoggingKeyMetadata = Readonly<{ mode: PublishedAiLoggingMode; at: number }>; + +/** Reads the metadata of a published key; anything malformed is `null` (treated as drift). */ +export function parseAiLoggingKeyMetadata(value: unknown): AiLoggingKeyMetadata | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return null; + } + const { mode, at } = value as Record; + if (!isPublishedAiLoggingMode(mode) || typeof at !== 'number' || !Number.isSafeInteger(at)) { + return null; + } + return { mode, at }; +} + +/** `platform_settings` key that holds plan defaults, kept apart from `plans:catalog`. */ +export const PLAN_AI_LOGGING_SETTING = 'plans:ai-logging'; + +/** + * A key for a project with no row may belong to a project whose create transaction + * has written KV but not committed yet. Cleanup leaves keys this young alone. + */ +export const AI_LOGGING_ORPHAN_GRACE_MS: number = 10 * 60_000; + +/** + * A plan-default fan-out that is still running (or stopped on an error). + * `revision` is the plan catalog revision that started it, so a page that began + * before a newer plan change cannot move the newer run's cursor. + */ +export type PlanAiLoggingPublish = Readonly<{ + revision: number; + planIds: ReadonlyArray; + includeUnknown: boolean; + cursor: string | null; + complete: boolean; + error?: string; +}>; + +export type PlanAiLoggingSettings = Readonly<{ + /** Plan ID -> default mode. A missing plan inherits. */ + modes: Readonly>; + publish: PlanAiLoggingPublish | null; +}>; + +function settingsRecord(value: unknown, name: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${name} must be an object.`); + } + return value as Record; +} + +function parsePlanAiLoggingPublish(value: unknown): PlanAiLoggingPublish | null { + if (value === null || value === undefined) { + return null; + } + const input = settingsRecord(value, 'AI logging publish'); + const { revision, planIds, includeUnknown, cursor, complete, error } = input; + if ( + !Number.isSafeInteger(revision) || + !Array.isArray(planIds) || + !planIds.every((id) => typeof id === 'string') || + typeof includeUnknown !== 'boolean' || + (cursor !== null && typeof cursor !== 'string') || + typeof complete !== 'boolean' || + (error !== undefined && typeof error !== 'string') + ) { + throw new Error('Invalid AI logging publish progress.'); + } + return { + revision: revision as number, + planIds: planIds as Array, + includeUnknown, + cursor, + complete, + ...(error === undefined ? {} : { error }), + }; +} + +/** + * Reads the saved plan defaults. A missing row means every plan inherits. A mode the + * platform does not know is refused (so a bad value never loosens logging); + * `inherit` entries are dropped. Entries for plans not in the catalog stay in the + * object but are never looked up (see `planAiLogging`). + */ +export function parsePlanAiLoggingSettings(value: unknown): PlanAiLoggingSettings { + if (value === null || value === undefined) { + return { modes: {}, publish: null }; + } + const input = settingsRecord(value, 'Plan AI logging settings'); + const saved = settingsRecord(input.modes ?? {}, 'Plan AI logging modes'); + const modes: Record = {}; + for (const [planId, mode] of Object.entries(saved)) { + if (!isAiLoggingMode(mode)) { + throw new Error(`Invalid AI logging mode for plan ${planId}.`); + } + if (mode !== 'inherit') { + modes[planId] = mode; + } + } + return { modes, publish: parsePlanAiLoggingPublish(input.publish) }; +} + +/** The parts of a plan catalog the logging resolver needs. */ +export type AiLoggingPlanCatalog = Readonly<{ + defaultPlanId: string; + plans: ReadonlyArray>; +}>; + +export type PlanAiLogging = Readonly<{ + planId: string; + /** False when the account's plan ID is not in the catalog. */ + planKnown: boolean; + /** The catalog default plan, whose logging an unknown plan ID follows. */ + defaultPlanId: string; + planMode: PublishedAiLoggingMode | null; +}>; + +/** + * The plan default for an account. A plan ID that is not in the catalog takes the + * default plan's logging (unlike limits, which fall back to the built-in free plan), + * so an operator who sets `off` on every plan gets `off` for every account. + */ +export function planAiLogging( + settings: PlanAiLoggingSettings, + catalog: AiLoggingPlanCatalog, + planId: string, +): PlanAiLogging { + const planKnown = catalog.plans.some((plan) => plan.id === planId); + const lookup = planKnown ? planId : catalog.defaultPlanId; + const planMode = Object.hasOwn(settings.modes, lookup) ? settings.modes[lookup]! : null; + return { planId, planKnown, defaultPlanId: catalog.defaultPlanId, planMode }; +} + +/** Whether a running plan publish still has to reach accounts on this plan. */ +export function planPublishPending( + publish: PlanAiLoggingPublish | null, + plan: Pick, +): boolean { + if (!publish || publish.complete) { + return false; + } + return plan.planKnown ? publish.planIds.includes(plan.planId) : publish.includeUnknown; +} + +/** + * Reads a plan's `aiLogging` field from a plan change. `undefined` keeps the current + * value and `inherit` clears it. + */ +export function parsePlanAiLoggingInput(value: unknown): AiLoggingMode | undefined { + if (value === undefined) { + return undefined; + } + if (!isAiLoggingMode(value)) { + throw new Error(`aiLogging must be one of ${AI_LOGGING_MODES.join(', ')}.`); + } + return value; +} diff --git a/packages/platform/test/unit/ai-logging.test.ts b/packages/platform/test/unit/ai-logging.test.ts new file mode 100644 index 000000000..9d40fbe57 --- /dev/null +++ b/packages/platform/test/unit/ai-logging.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + AI_LOGGING_MODES, + aiLoggingKey, + isAiLoggingMode, + isPublishedAiLoggingMode, + parseAiLoggingKeyMetadata, + parsePlanAiLoggingInput, + parsePlanAiLoggingSettings, + parsePublishedAiLogging, + planAiLogging, + planPublishPending, + publishOrder, + resolveAiLogging, +} from '../../src/ai-logging.ts'; + +describe('AI logging policy', () => { + it('accepts exactly the four operator modes', () => { + expect(AI_LOGGING_MODES).toEqual(['inherit', 'off', 'metadata', 'full']); + for (const mode of AI_LOGGING_MODES) { + expect(isAiLoggingMode(mode)).toBe(true); + } + for (const value of ['OFF', ' off', '', 'none', null, undefined, 0, false, {}]) { + expect(isAiLoggingMode(value)).toBe(false); + } + }); + + it('never stores inherit', () => { + expect(isPublishedAiLoggingMode('inherit')).toBe(false); + expect(['off', 'metadata', 'full'].every(isPublishedAiLoggingMode)).toBe(true); + }); + + it('names the ROUTING key after the project', () => { + expect(aiLoggingKey('proj_000000000042')).toBe('ai-logging:proj_000000000042'); + }); + + it('reads a missing key as inherit and refuses values it does not know', () => { + expect(parsePublishedAiLogging(null)).toBe('inherit'); + expect(parsePublishedAiLogging('off')).toBe('off'); + expect(parsePublishedAiLogging('metadata')).toBe('metadata'); + expect(parsePublishedAiLogging('full')).toBe('full'); + for (const value of ['inherit', '', 'OFF', 'off ', '"off"', 'future-mode']) { + expect(parsePublishedAiLogging(value)).toBeUndefined(); + } + }); + + it('prefers the project override, then the plan default, then the gateway', () => { + expect(resolveAiLogging('full', 'off')).toEqual({ mode: 'full', source: 'project' }); + expect(resolveAiLogging('off', 'full')).toEqual({ mode: 'off', source: 'project' }); + expect(resolveAiLogging(null, 'metadata')).toEqual({ mode: 'metadata', source: 'plan' }); + expect(resolveAiLogging('inherit', 'off')).toEqual({ mode: 'off', source: 'plan' }); + expect(resolveAiLogging(undefined, 'inherit')).toEqual({ mode: 'inherit', source: 'gateway' }); + expect(resolveAiLogging(null, null)).toEqual({ mode: 'inherit', source: 'gateway' }); + }); + + it('orders known changes and treats any change with inherit as unknown', () => { + expect(publishOrder('full', 'off')).toBe('tighten'); + expect(publishOrder('full', 'metadata')).toBe('tighten'); + expect(publishOrder('metadata', 'off')).toBe('tighten'); + expect(publishOrder('off', 'metadata')).toBe('loosen'); + expect(publishOrder('off', 'full')).toBe('loosen'); + expect(publishOrder('metadata', 'full')).toBe('loosen'); + for (const mode of AI_LOGGING_MODES) { + expect(publishOrder(mode, mode)).toBe('same'); + } + for (const mode of ['off', 'metadata', 'full'] as const) { + expect(publishOrder('inherit', mode)).toBe('unknown'); + expect(publishOrder(mode, 'inherit')).toBe('unknown'); + } + }); + + it('orders by effective values, not by the saved project field', () => { + // A project override removed (`full` -> no row) while its plan says `off`. + const before = resolveAiLogging('full', 'off').mode; + const after = resolveAiLogging(null, 'off').mode; + expect(publishOrder(before, after)).toBe('tighten'); + }); + + it('reads key metadata and treats anything malformed as missing', () => { + expect(parseAiLoggingKeyMetadata({ mode: 'off', at: 1700000000000 })).toEqual({ + mode: 'off', + at: 1700000000000, + }); + for (const value of [ + null, + undefined, + 'off', + [], + { mode: 'inherit', at: 1 }, + { mode: 'off' }, + { mode: 'off', at: '1' }, + { mode: 'off', at: 1.5 }, + ]) { + expect(parseAiLoggingKeyMetadata(value)).toBeNull(); + } + }); +}); + +describe('plan AI logging defaults', () => { + const catalog = { defaultPlanId: 'free', plans: [{ id: 'free' }, { id: 'pro' }] }; + + it('reads a missing row as every plan inheriting', () => { + expect(parsePlanAiLoggingSettings(null)).toEqual({ modes: {}, publish: null }); + expect(planAiLogging(parsePlanAiLoggingSettings(null), catalog, 'pro')).toEqual({ + planId: 'pro', + planKnown: true, + defaultPlanId: 'free', + planMode: null, + }); + }); + + it('keeps known modes, drops inherit and refuses modes it does not know', () => { + const settings = parsePlanAiLoggingSettings({ + modes: { pro: 'metadata', free: 'inherit', gone: 'off' }, + publish: null, + }); + expect(settings.modes).toEqual({ pro: 'metadata', gone: 'off' }); + for (const modes of [{ pro: 'OFF' }, { pro: null }, { pro: 1 }, []]) { + expect(() => parsePlanAiLoggingSettings({ modes, publish: null })).toThrow(); + } + expect(() => parsePlanAiLoggingSettings('off')).toThrow(); + expect(() => + parsePlanAiLoggingSettings({ modes: {}, publish: { planIds: 'pro', complete: false } }), + ).toThrow(); + }); + + it('ignores entries for plans that are no longer in the catalog', () => { + const settings = parsePlanAiLoggingSettings({ modes: { gone: 'off' } }); + expect(planAiLogging(settings, catalog, 'free').planMode).toBeNull(); + expect(planAiLogging(settings, catalog, 'pro').planMode).toBeNull(); + }); + + it('gives an account on an unknown plan the logging of the default plan', () => { + const settings = parsePlanAiLoggingSettings({ modes: { free: 'off', pro: 'full' } }); + expect(planAiLogging(settings, catalog, 'legacy-x')).toEqual({ + planId: 'legacy-x', + planKnown: false, + defaultPlanId: 'free', + planMode: 'off', + }); + expect(planAiLogging(settings, catalog, 'pro').planMode).toBe('full'); + }); + + it('knows which accounts a running plan publish still has to reach', () => { + const publish = parsePlanAiLoggingSettings({ + modes: {}, + publish: { + revision: 3, + planIds: ['pro'], + includeUnknown: true, + cursor: null, + complete: false, + }, + }).publish!; + expect(planPublishPending(publish, { planId: 'pro', planKnown: true })).toBe(true); + expect(planPublishPending(publish, { planId: 'free', planKnown: true })).toBe(false); + expect(planPublishPending(publish, { planId: 'legacy-x', planKnown: false })).toBe(true); + expect( + planPublishPending({ ...publish, complete: true }, { planId: 'pro', planKnown: true }), + ).toBe(false); + expect(planPublishPending(null, { planId: 'pro', planKnown: true })).toBe(false); + }); + + it('requires the catalog revision that started a publish', () => { + const publish = { planIds: ['pro'], includeUnknown: false, cursor: null, complete: false }; + expect(() => parsePlanAiLoggingSettings({ modes: {}, publish })).toThrow(); + expect( + parsePlanAiLoggingSettings({ modes: {}, publish: { ...publish, revision: 4, error: 'x' } }) + .publish, + ).toEqual({ ...publish, revision: 4, error: 'x' }); + }); + + it('reads a plan change: omitted keeps, inherit clears, anything else is refused', () => { + expect(parsePlanAiLoggingInput(undefined)).toBeUndefined(); + for (const mode of AI_LOGGING_MODES) { + expect(parsePlanAiLoggingInput(mode)).toBe(mode); + } + for (const value of [null, '', 'OFF', 'everything', 1]) { + expect(() => parsePlanAiLoggingInput(value)).toThrow('aiLogging must be one of'); + } + }); +}); diff --git a/packages/platform/vite.config.ts b/packages/platform/vite.config.ts index d6cbc5da5..84c7423a5 100644 --- a/packages/platform/vite.config.ts +++ b/packages/platform/vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ 'src/installer.ts', 'src/email-protocol.ts', 'src/plans.ts', + 'src/ai-logging.ts', 'src/browser-page.ts', ], format: 'esm', diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index fdc2e125b..83293aff8 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -61,12 +61,12 @@ type PlatformVersionProbe = | { kind: 'unknown' }; /** - * Returns whether `version` is older than MIN_PLATFORM_VERSION, or null when - * it is not a release version. A prerelease sorts before its release. + * Returns whether `version` is older than the release `minimumVersion`, or null + * when it is not a release version. A prerelease sorts before its release. */ -function isOlderThanMinimumPlatform(version: string): boolean | null { +export function isOlderPlatformVersion(version: string, minimumVersion: string): boolean | null { const actual = RELEASE_VERSION.exec(version); - const minimum = RELEASE_VERSION.exec(MIN_PLATFORM_VERSION)!; + const minimum = RELEASE_VERSION.exec(minimumVersion)!; if (!actual) { return null; } @@ -99,7 +99,7 @@ function assertSupportedPlatform(apiUrl: string, probe: PlatformVersionProbe): v // comes from a hosted deployment that does not set PLATFORM_VERSION yet. // Any other value that is not a release version comes from a custom or // development build and cannot be ordered. Both pass. - if (version === 'hosted' || isOlderThanMinimumPlatform(version) !== true) { + if (version === 'hosted' || isOlderPlatformVersion(version, MIN_PLATFORM_VERSION) !== true) { return; } throw new Error( diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 170113af8..a69948db2 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -337,7 +337,7 @@ const HELP_TREE: HelpTree = { }, { command: 'set ', - description: 'Edit a plan name, limits, or build instance size', + description: 'Edit a plan name, limits, build size, or AI Gateway logging default', }, { command: 'reset ', @@ -356,7 +356,10 @@ const HELP_TREE: HelpTree = { description: 'Remove an unused plan or move its accounts to a replacement', }, { command: 'default ', description: 'Choose the plan new accounts receive' }, - { command: 'sync', description: 'Continue pending account limit updates' }, + { + command: 'sync', + description: 'Continue pending account limit and AI logging updates', + }, ], options: [ { @@ -365,7 +368,7 @@ const HELP_TREE: HelpTree = { }, { command: '--file ', - description: 'Read plan settings and partial limits from JSON', + description: 'Read plan settings, partial limits, and aiLogging from JSON', }, { command: '--name ', diff --git a/packages/void/src/cli/operator-ai-logging.ts b/packages/void/src/cli/operator-ai-logging.ts new file mode 100644 index 000000000..0d117050c --- /dev/null +++ b/packages/void/src/cli/operator-ai-logging.ts @@ -0,0 +1,169 @@ +import { AI_LOGGING_SETTLE_MS } from '@void/platform/ai-logging'; +import { cfAccessHeaders } from '../shared/cf-access.ts'; +import { ensurePlatformAccessToken } from './cf-access.ts'; +import { OperatorClientError } from './operator-client.ts'; +import { sanitizeTerminalText } from './output.ts'; +import { readPlatformDiscoveryResponse } from './platform-discovery.ts'; + +/** The first platform release with per-project AI logging settings. */ +export const AI_LOGGING_PLATFORM_VERSION = '1.8.0'; + +type Fields = Record; + +function text(value: unknown): string { + return typeof value === 'string' ? sanitizeTerminalText(value) : ''; +} + +function record(value: unknown): Fields | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Fields) : null; +} + +/** `14:03:20 UTC`, or null when the value is not a time. */ +export function utcTime(value: unknown, offsetMs = 0): string | null { + const time = typeof value === 'string' ? Date.parse(value) : Number.NaN; + if (Number.isNaN(time)) { + return null; + } + return `${new Date(time + offsetMs).toISOString().slice(11, 19)} UTC`; +} + +/** `plan "pro"`, naming the default plan an unknown plan ID follows. */ +function planLabel(fields: Fields): string { + const plan = `plan "${text(fields.planId)}"`; + if (fields.planKnown === false) { + return `${plan} (not in catalog; uses default plan "${text(fields.defaultPlanId)}")`; + } + return plan; +} + +/** Where the effective mode comes from, or null for a platform that does not say. */ +function sourceText(fields: Fields): string | null { + if (typeof fields.planId !== 'string') { + return null; + } + const planDefault = `${planLabel(fields)} default: ${text(fields.planMode) || 'inherit'}`; + switch (fields.source) { + case 'project': + return `set on project; ${planDefault}`; + case 'plan': + return `from ${planLabel(fields)}`; + case 'gateway': + return `AI Gateway setting; ${planDefault}`; + default: + return null; + } +} + +function effective(fields: Fields): string { + return text(fields.effective) || text(fields.mode); +} + +function withSource(mode: string, fields: Fields): string { + const source = sourceText(fields); + return source ? `${mode} (${source})` : mode; +} + +/** The `project ai-logging` result: the setting and when new requests follow it. */ +export function aiLoggingChangeLines(projectId: string, result: Fields): Array { + const lines = [ + `AI logging for ${sanitizeTerminalText(projectId)}: ${withSource(effective(result), result)}`, + ]; + const expectedBy = utcTime(result.expectedBy); + if (expectedBy) { + lines.push( + `New AI requests are expected to follow it by ${expectedBy} (normally within 2 minutes).`, + ); + } + return lines; +} + +/** The `project ai-logging --plan` preview: current and new effective modes. */ +export function aiLoggingPreviewLines(projectId: string, preview: Fields): Array { + const previous = text(preview.previous); + const next = effective(preview); + const change = previous ? `${previous} → ${next}` : next; + return [ + `AI logging for ${sanitizeTerminalText(projectId)}: ${withSource(change, preview)}`, + 'No change was made. Run the command again without --plan to apply it.', + ]; +} + +const STATES: Record string> = { + applied: () => 'applied', + pending: (fields) => { + const by = utcTime(fields.publishedAt, AI_LOGGING_SETTLE_MS); + return by ? `pending (expected by ${by})` : 'pending (normally within 2 minutes)'; + }, + pending_plan_publish: () => 'pending (the plan default is still being published)', + drift: (fields) => `drift (published: ${text(fields.published) || 'unknown value'})`, + unknown: () => 'state unknown (the published setting could not be read)', +}; + +/** + * `project show` line, such as `metadata (from plan "pro") — applied`, or null + * when the response has no AI logging data. + */ +export function aiLoggingStatusLine(value: unknown): string | null { + const fields = record(value); + if (!fields || typeof fields.mode !== 'string') { + return null; + } + const state = + typeof fields.state === 'string' && Object.hasOwn(STATES, fields.state) + ? STATES[fields.state] + : undefined; + const summary = withSource(effective(fields), fields); + return `AI Gateway logging: ${state ? `${summary} — ${state(fields)}` : summary}`; +} + +type DiscoveryProbe = { kind: 'version'; version: string } | { kind: 'missing' | 'unknown' }; + +async function probePlatformVersion(origin: string): Promise { + ensurePlatformAccessToken(origin); + let response: Response; + try { + response = await fetch(`${origin}/.well-known/void-platform`, { + headers: { Accept: 'application/json', ...cfAccessHeaders(process.env, origin) }, + redirect: 'manual', + signal: AbortSignal.timeout(15_000), + }); + } catch { + return { kind: 'unknown' }; + } + if (response.status === 404) { + await response.body?.cancel(); + return { kind: 'missing' }; + } + try { + return { + kind: 'version', + version: (await readPlatformDiscoveryResponse(origin, response)).platformVersion, + }; + } catch { + return { kind: 'unknown' }; + } +} + +/** + * Refuses `project ai-logging` on a platform before AI logging settings existed. + * It reads the platform version instead of guessing from a 404, because "Project + * not found" is also a 404. When the version cannot be read, the request runs and + * reports its own error. + */ +export async function assertAiLoggingPlatform(origin: string): Promise { + const probe = await probePlatformVersion(origin); + if (probe.kind === 'unknown') { + return; + } + if (probe.kind === 'version') { + const { isOlderPlatformVersion } = await import('./client.ts'); + // "hosted" and development builds are not release versions and pass. + if (isOlderPlatformVersion(probe.version, AI_LOGGING_PLATFORM_VERSION) !== true) { + return; + } + } + throw new OperatorClientError( + 'This platform does not support AI logging policies. Run `void platform upgrade`.', + 'unsupported', + ); +} diff --git a/packages/void/src/cli/operator-args.ts b/packages/void/src/cli/operator-args.ts index 79e39d5ce..70f715132 100644 --- a/packages/void/src/cli/operator-args.ts +++ b/packages/void/src/cli/operator-args.ts @@ -104,7 +104,7 @@ export const operatorCommands: Array = [ { group: 'project', action: 'show', - description: 'Inspect a project and its resources', + description: 'Inspect a project, its resources, and its effective AI Gateway logging', path: '/projects/:id', positional: ['id'], }, @@ -127,9 +127,11 @@ export const operatorCommands: Array = [ { group: 'project', action: 'ai-logging', - description: 'Set AI Gateway logging: inherit, off, metadata, or full', + description: + 'Set AI Gateway logging (inherit, off, metadata, or full); new AI requests normally follow it within 2 minutes', path: '/projects/:id/ai-logging', positional: ['id', 'mode'], + usage: ['void platform project ai-logging [options]'], method: 'POST', }, { diff --git a/packages/void/src/cli/operator-cmd.ts b/packages/void/src/cli/operator-cmd.ts index 2bb5b2ab9..9876af11a 100644 --- a/packages/void/src/cli/operator-cmd.ts +++ b/packages/void/src/cli/operator-cmd.ts @@ -1,6 +1,12 @@ import { createHash } from 'node:crypto'; import { createInterface } from 'node:readline/promises'; import { setTimeout as delay } from 'node:timers/promises'; +import { + aiLoggingChangeLines, + aiLoggingPreviewLines, + aiLoggingStatusLine, + assertAiLoggingPlatform, +} from './operator-ai-logging.ts'; import type { OperatorArgs } from './operator-args.ts'; import { exchangeOperatorSession, @@ -649,6 +655,11 @@ async function migrateDeploymentAssets( } } +/** The project ID from the request path `/projects/:id/ai-logging`. */ +function aiLoggingProjectId(args: OperatorArgs): string { + return decodeURIComponent(args.path.slice('/projects/'.length, -'/ai-logging'.length)); +} + export async function runOperatorCommand(args: OperatorArgs): Promise { if (!args.action) { const { printHelpPage } = await import('./help.ts'); @@ -671,6 +682,10 @@ export async function runOperatorCommand(args: OperatorArgs): Promise { if (args.group === 'deployment' && args.action === 'migrate-assets') { return migrateDeploymentAssets(client, origin, args); } + const aiLogging = args.group === 'project' && args.action === 'ai-logging'; + if (aiLogging) { + await assertAiLoggingPlatform(origin); + } // Read once for preview and apply; the server excludes the token from audit output. if ( args.tokenStdin && @@ -725,6 +740,10 @@ export async function runOperatorCommand(args: OperatorArgs): Promise { 'unsupported', ); } + if (aiLogging && args.plan && !args.json) { + console.log(aiLoggingPreviewLines(aiLoggingProjectId(args), preview).join('\n')); + return; + } if (!(await confirmOperatorMutation(origin, preview, args))) { return; } @@ -738,10 +757,16 @@ export async function runOperatorCommand(args: OperatorArgs): Promise { : {}), ...recoveryFence, }; - outputMutation( - await client.request(args.path, { method: 'POST', body, timeoutMs: args.timeoutMs }), - args, - ); + const result = await client.request(args.path, { + method: 'POST', + body, + timeoutMs: args.timeoutMs, + }); + if (aiLogging && !args.json) { + console.log(aiLoggingChangeLines(aiLoggingProjectId(args), result).join('\n')); + return; + } + outputMutation(result, args); } else if (args.follow) { await followOperatorLogs(client, args); } else { @@ -762,6 +787,11 @@ export async function runOperatorCommand(args: OperatorArgs): Promise { ...eventLines(result.deployments, ' '), ].join('\n'), ); + } else if (args.group === 'project' && args.action === 'show' && !args.json) { + // One readable line instead of the raw AI logging fields; --json keeps them all. + const { aiLogging, ...project } = result; + const line = aiLoggingStatusLine(aiLogging); + console.log((line ? [...humanLines(project), line] : humanLines(result)).join('\n')); } else { output(result, args.json); } diff --git a/packages/void/src/cli/platform-ai-logging-projection.ts b/packages/void/src/cli/platform-ai-logging-projection.ts new file mode 100644 index 000000000..3609507dd --- /dev/null +++ b/packages/void/src/cli/platform-ai-logging-projection.ts @@ -0,0 +1,249 @@ +import { + AI_LOGGING_ORPHAN_GRACE_MS, + aiLoggingClearedKey, + aiLoggingClearedMarker, + aiLoggingKey, + isPublishedAiLoggingMode, + parseAiLoggingKeyMetadata, + parsePlanAiLoggingSettings, + PLAN_AI_LOGGING_SETTING, + planAiLogging, + resolveAiLogging, + type AiLoggingPlanCatalog, + type PlanAiLoggingSettings, +} from '@void/platform/ai-logging'; +import { createDefaultPlanCatalog, parsePlatformPlanCatalog } from '@void/platform/plans'; +import type { + CloudflareKvBulkEntry, + CloudflareKvKey, + PlatformCloudflareClient, +} from './platform-cloudflare.ts'; +import type { PlatformDatabaseLifecycleLock } from './platform-installer-types.ts'; +import type { QueryConnection } from './platform-zero-trust-uninstall.ts'; + +const KEY_PREFIX = aiLoggingKey(''); +const PLAN_CATALOG_SETTING = 'plans:catalog'; +/** Wait at most this long for a project row lock held by the API. */ +const LOCK_TIMEOUT = '30s'; + +/** ROUTING KV access for one installation, bound to its account and namespace. */ +export type AiLoggingProjectionKv = Readonly<{ + list: (prefix: string) => Promise>; + put: (entries: ReadonlyArray) => Promise; + delete: (keys: ReadonlyArray) => Promise; +}>; + +export type AiLoggingProjectionResult = + | Readonly<{ skipped: true }> + | Readonly<{ skipped: false; projects: number; written: number; deleted: number }>; + +type ProjectRow = { id: string; status: string; plan: string; mode: string | null }; + +type Inputs = Readonly<{ + catalog: AiLoggingPlanCatalog; + settings: PlanAiLoggingSettings; +}>; + +async function readInputs(client: QueryConnection): Promise { + const result = await client.query<{ key: string; value: unknown }>( + 'SELECT "key", "value" FROM "platform_settings" WHERE "key" = ANY($1::text[])', + [[PLAN_CATALOG_SETTING, PLAN_AI_LOGGING_SETTING]], + ); + const saved = new Map(result.rows.map((row) => [row.key, row.value])); + let catalog: AiLoggingPlanCatalog; + let settings: PlanAiLoggingSettings; + try { + // Installed platforms start with the custom plan as the default (see the API's + // initialPlanDefaultFor). + const savedCatalog = saved.get(PLAN_CATALOG_SETTING); + catalog = + savedCatalog === undefined || savedCatalog === null + ? createDefaultPlanCatalog('custom') + : parsePlatformPlanCatalog(savedCatalog); + settings = parsePlanAiLoggingSettings(saved.get(PLAN_AI_LOGGING_SETTING)); + } catch (error) { + throw new Error( + `platform: The saved plan settings are not valid for this platform version, so AI logging settings cannot be published. If a newer platform release saved them, upgrade to that release again. (${error instanceof Error ? error.message : String(error)})`, + ); + } + return { catalog, settings }; +} + +function effectiveMode(row: ProjectRow, inputs: Inputs) { + if (row.mode !== null && !isPublishedAiLoggingMode(row.mode)) { + throw new Error(`platform: Project ${row.id} has an invalid saved AI logging setting.`); + } + const plan = planAiLogging(inputs.settings, inputs.catalog, row.plan); + return resolveAiLogging(row.mode, plan.planMode).mode; +} + +/** + * The database server's clock in ms. Readback and the sweep compare `at` with the + * API's clock, so it must not come from the operator's machine. + */ +async function databaseNow(client: QueryConnection): Promise { + const result = await client.query<{ now: unknown }>( + 'SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::float8 AS "now"', + ); + const now = Number(result.rows[0]?.now); + if (!Number.isSafeInteger(now)) { + throw new Error('platform: The database did not return its current time.'); + } + return now; +} + +const PROJECT_ROWS = `SELECT p."id", p."status", u."plan", o."mode" + FROM "projects" AS p + JOIN "users" AS u ON u."id" = p."user_id" + LEFT JOIN "project_ai_logging" AS o ON o."project_id" = p."id"`; + +/** + * Publishes every project's effective AI logging mode to ROUTING KV from Postgres, + * the same values the API publishes. The installer runs it on every install, upgrade + * and repair, once before the Workers upload (a new proxy may go live before the new + * API) and once after (the old API may have saved changes meanwhile). + * + * It holds the project row locks the API publisher takes while it reads the inputs + * and writes KV, so it never overwrites a newer value the API published. Running it + * again changes nothing. + */ +export async function projectPlatformAiLoggingConnection( + client: QueryConnection, + kv: AiLoggingProjectionKv, + options: Readonly<{ + /** Overrides the database clock (tests). */ + now?: () => number; + onProgress?: (message: string) => void; + }> = {}, +): Promise { + const tables = await client.query<{ present: boolean }>( + `SELECT to_regclass('public.project_ai_logging') IS NOT NULL AS "present"`, + ); + if (tables.rows[0]?.present !== true) { + // Older schema (or a rollback runtime before 1.8.0): nothing is saved yet. + return { skipped: true }; + } + const listed = new Map(); + for (const key of await kv.list(KEY_PREFIX)) { + if (key.name.startsWith(KEY_PREFIX) && key.name.length > KEY_PREFIX.length) { + listed.set(key.name.slice(KEY_PREFIX.length), key); + } + } + + await client.query('BEGIN'); + try { + await client.query(`SET LOCAL lock_timeout = '${LOCK_TIMEOUT}'`); + // Pick candidates: projects with an override, projects whose plan has a default, + // and projects that already have a key (so stale keys are removed). + const before = await readInputs(client); + const rows = (await client.query(PROJECT_ROWS)).rows; + const candidates = rows + .filter( + (row) => + row.mode !== null || listed.has(row.id) || effectiveMode(row, before) !== 'inherit', + ) + .map((row) => row.id); + options.onProgress?.(`Publishing AI logging settings (${candidates.length} projects)`); + // Same lock as the API publisher's no-op UPDATE (so rows that reference a project + // can still be inserted), taken in id order. Inputs are read again after the + // locks are held, so a change the API committed meanwhile is the one written. + if (candidates.length > 0) { + await client.query( + 'SELECT "id" FROM "projects" WHERE "id" = ANY($1::text[]) ORDER BY "id" FOR NO KEY UPDATE', + [candidates], + ); + } + const inputs = await readInputs(client); + const locked = new Set(candidates); + const current = new Map( + ( + await client.query(`${PROJECT_ROWS} WHERE p."id" = ANY($1::text[])`, [ + candidates, + ]) + ).rows.map((row) => [row.id, row]), + ); + const at = options.now ? options.now() : await databaseNow(client); + const writes: Array = []; + const deletes: Array = []; + let markers = 0; + for (const id of locked) { + const row = current.get(id); + if (!row) { + continue; + } + if (row.status !== 'active') { + // Deletion is in progress; its cleanup job removes the key. + continue; + } + const mode = effectiveMode(row, inputs); + if (mode !== 'inherit') { + writes.push({ key: aiLoggingKey(id), value: mode, metadata: { mode, at } }); + } else if (listed.has(id)) { + // The marker lets readback report `pending` while the old value may linger. + const marker = aiLoggingClearedMarker(at); + writes.push({ + key: aiLoggingClearedKey(id), + value: marker.value, + metadata: marker.metadata, + expiration_ttl: marker.expirationTtl, + }); + markers++; + deletes.push(aiLoggingKey(id)); + } + } + const existing = new Set(rows.map((row) => row.id)); + for (const [id, key] of listed) { + if (existing.has(id) || current.has(id)) { + continue; + } + // No project row: the project is gone, unless it was created moments ago. + const metadata = parseAiLoggingKeyMetadata(key.metadata); + if (!metadata || at - metadata.at >= AI_LOGGING_ORPHAN_GRACE_MS) { + deletes.push(key.name); + } + } + if (writes.length > 0) { + await kv.put(writes); + } + if (deletes.length > 0) { + await kv.delete(deletes); + } + await client.query('COMMIT'); + return { + skipped: false, + projects: locked.size, + written: writes.length - markers, + deleted: deletes.length, + }; + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined); + throw error; + } +} + +/** + * Runs the projection through the installer's database lock, with a KV client bound + * to the installation's ROUTING namespace. A lock without it (test doubles) skips it. + */ +export async function publishInstallationAiLogging(input: { + readonly client: Pick; + readonly accountId: string; + readonly routingKvId: string; + readonly databaseLock: Pick; + readonly onProgress?: (message: string) => void; +}): Promise { + const publish = input.databaseLock.publishAiLogging; + if (!publish) { + return null; + } + const { client, accountId, routingKvId } = input; + input.onProgress?.('Publishing AI logging settings'); + return publish( + { + list: (prefix) => client.listKvKeys(accountId, routingKvId, prefix), + put: (entries) => client.putKvBulk(accountId, routingKvId, entries), + delete: (keys) => client.deleteKvBulk(accountId, routingKvId, keys), + }, + input.onProgress, + ); +} diff --git a/packages/void/src/cli/platform-cloudflare.ts b/packages/void/src/cli/platform-cloudflare.ts index 3d3b1341b..f430acb61 100644 --- a/packages/void/src/cli/platform-cloudflare.ts +++ b/packages/void/src/cli/platform-cloudflare.ts @@ -66,6 +66,17 @@ export class PlatformRuntimeTokenEmailPermissionError extends Error { } export type CloudflareAccount = Readonly<{ id: string; name: string }>; +/** A key from a KV listing; `metadata` is whatever was stored with it, or undefined. */ +export type CloudflareKvKey = Readonly<{ name: string; metadata?: unknown }>; +export type CloudflareKvBulkEntry = Readonly<{ + key: string; + value: string; + metadata?: unknown; + /** Seconds until the pair expires (at least 60). */ + expiration_ttl?: number; +}>; +/** Largest number of pairs or keys one KV bulk request accepts. */ +export const KV_BULK_LIMIT = 10_000; export type CloudflareNamedResource = Readonly<{ /** When Cloudflare created it, where the API reports that. */ createdAt?: string; @@ -931,6 +942,74 @@ export class PlatformCloudflareClient { ); } + /** Every key under `prefix`, with its metadata, across all list pages. */ + async listKvKeys( + accountId: string, + namespaceId: string, + prefix: string, + ): Promise> { + const keys: Array = []; + const seenCursors = new Set(); + let cursor = ''; + for (;;) { + const query = new URLSearchParams({ prefix, limit: '1000' }); + if (cursor) { + query.set('cursor', cursor); + } + const { result, resultInfo } = await this.#requestEnvelope< + ReadonlyArray<{ name?: unknown; metadata?: unknown }> + >( + `/accounts/${encodeURIComponent(accountId)}/storage/kv/namespaces/${encodeURIComponent(namespaceId)}/keys?${query}`, + ); + if (!Array.isArray(result)) { + throw new Error('platform: Cloudflare returned an invalid KV key list.'); + } + for (const entry of result) { + const name = requiredString(entry.name, 'KV key name'); + keys.push(entry.metadata === undefined ? { name } : { name, metadata: entry.metadata }); + } + const next = resultInfo?.cursor ?? ''; + if (!next) { + return keys; + } + if (seenCursors.has(next)) { + throw new Error('platform: Cloudflare returned a repeated KV key cursor.'); + } + seenCursors.add(next); + cursor = next; + } + } + + /** Writes up to KV_BULK_LIMIT pairs per request; fails if any key is not written. */ + async putKvBulk( + accountId: string, + namespaceId: string, + entries: ReadonlyArray, + ): Promise { + for (let offset = 0; offset < entries.length; offset += KV_BULK_LIMIT) { + const result = await this.request( + `/accounts/${encodeURIComponent(accountId)}/storage/kv/namespaces/${encodeURIComponent(namespaceId)}/bulk`, + { method: 'PUT', body: JSON.stringify(entries.slice(offset, offset + KV_BULK_LIMIT)) }, + ); + assertKvBulkComplete(result, 'write'); + } + } + + /** Deletes up to KV_BULK_LIMIT keys per request; a missing key counts as deleted. */ + async deleteKvBulk( + accountId: string, + namespaceId: string, + keys: ReadonlyArray, + ): Promise { + for (let offset = 0; offset < keys.length; offset += KV_BULK_LIMIT) { + const result = await this.request( + `/accounts/${encodeURIComponent(accountId)}/storage/kv/namespaces/${encodeURIComponent(namespaceId)}/bulk/delete`, + { method: 'POST', body: JSON.stringify(keys.slice(offset, offset + KV_BULK_LIMIT)) }, + ); + assertKvBulkComplete(result, 'delete'); + } + } + async getKvValue(accountId: string, namespaceId: string, key: string): Promise { const response = await this.#fetcher( `${this.apiBaseUrl}/accounts/${encodeURIComponent(accountId)}/storage/kv/namespaces/${encodeURIComponent(namespaceId)}/values/${encodeURIComponent(key)}`, @@ -1523,6 +1602,19 @@ function withCreatedAt(value: unknown): { createdAt?: string } { return Number.isNaN(time) ? {} : { createdAt: new Date(time).toISOString() }; } +type KvBulkResult = { successful_key_count?: unknown; unsuccessful_keys?: unknown } | null; + +/** A bulk call answers 200 even when some keys failed; treat any failed key as an error. */ +function assertKvBulkComplete(result: KvBulkResult, operation: 'write' | 'delete'): void { + const failed = result?.unsuccessful_keys; + if (failed !== undefined && failed !== null && (!Array.isArray(failed) || failed.length > 0)) { + const count = Array.isArray(failed) ? failed.length : 'some'; + throw new Error( + `platform: Cloudflare did not ${operation} ${count} KV keys in a bulk request.`, + ); + } +} + function requiredString(value: unknown, label: string): string { if (typeof value !== 'string' || value === '') { throw new Error(`platform: ${label} is missing from the Cloudflare response.`); diff --git a/packages/void/src/cli/platform-install-provisioning.ts b/packages/void/src/cli/platform-install-provisioning.ts index 95e3e8439..386778316 100644 --- a/packages/void/src/cli/platform-install-provisioning.ts +++ b/packages/void/src/cli/platform-install-provisioning.ts @@ -5,6 +5,7 @@ import { } from '@void/platform'; import type { PlatformRuntimeManifest } from '@void/platform/installer'; import { join } from 'pathslash'; +import { publishInstallationAiLogging } from './platform-ai-logging-projection.ts'; import { verifyPlatformAuthenticationDatabase } from './platform-auth-database.ts'; import { preparePlatformDatabaseSchema } from './platform-installer-database.ts'; import { @@ -189,6 +190,18 @@ export async function provisionInstallationServices(input: { }, ); + // First pass: the new proxy reads only ROUTING KV and may go live before the new API. + await publishInstallationAiLogging({ + client: dependencies.client, + accountId: plan.account.id, + routingKvId: requireId( + Object.fromEntries(state.manifest.resources.map((resource) => [resource.key, resource.id])), + 'routing-kv', + ), + databaseLock: releaseDatabaseLock, + onProgress: dependencies.onProgress, + }); + dependencies.onProgress?.('Checking platform safety'); const targetSandboxDrainProtocol = runtime.safetyProtocols.sandboxDrain; const prepareSandboxDrain = diff --git a/packages/void/src/cli/platform-installer-database.ts b/packages/void/src/cli/platform-installer-database.ts index 6add1ac53..09846d92b 100644 --- a/packages/void/src/cli/platform-installer-database.ts +++ b/packages/void/src/cli/platform-installer-database.ts @@ -14,6 +14,7 @@ import type { import { platformDatabaseSchemaDigest } from '@void/platform/installer'; import { join } from 'pathslash'; import type { Pool } from 'pg'; +import { projectPlatformAiLoggingConnection } from './platform-ai-logging-projection.ts'; import { verifyPlatformDatabaseArtifacts } from './platform-installer-runtime.ts'; import { type PlatformAdministratorSetupState, @@ -627,6 +628,8 @@ export async function acquirePlatformDatabaseLifecycleLockConnection( release.readAdministratorSetup = () => readPlatformAdministratorSetupConnection(client); release.readProjectZeroTrust = () => readPlatformProjectZeroTrustStateConnection(client); release.readProjects = () => readPlatformProjectSlugsConnection(client); + release.publishAiLogging = (kv, onProgress) => + projectPlatformAiLoggingConnection(client, kv, { onProgress }); release.lockProjectZeroTrust = () => lockPlatformProjectZeroTrustConnection(client); release.fenceProjectZeroTrust = () => fencePlatformProjectZeroTrustConnection(client); release.liftProjectZeroTrustFence = () => liftPlatformProjectZeroTrustFenceConnection(client); diff --git a/packages/void/src/cli/platform-installer-types.ts b/packages/void/src/cli/platform-installer-types.ts index 544abeba8..46a4be4ea 100644 --- a/packages/void/src/cli/platform-installer-types.ts +++ b/packages/void/src/cli/platform-installer-types.ts @@ -13,6 +13,10 @@ import type { } from '@void/platform/installer'; import type { ensurePlatformAccessHealthChecks } from './platform-access-health.ts'; import type { verifyInstalledPlatformAccessProtection } from './platform-access-preflight.ts'; +import type { + AiLoggingProjectionKv, + AiLoggingProjectionResult, +} from './platform-ai-logging-projection.ts'; import type { initializeInstalledAppAccess } from './platform-app-access.ts'; import type { initializeInstalledPlatformAuthentication } from './platform-auth-bootstrap.ts'; import { @@ -125,6 +129,14 @@ export type PlatformDatabaseLifecycleLock = (() => Promise) & { readAdministratorSetup?: () => Promise; /** Project Zero Trust state that decides whether uninstall may start. */ readProjectZeroTrust?: () => Promise; + /** + * Publishes every project's AI logging setting from the database to ROUTING KV + * under the project row locks. Missing on test locks, which then skip it. + */ + publishAiLogging?: ( + kv: AiLoggingProjectionKv, + onProgress?: (message: string) => void, + ) => Promise; /** Project slugs, which --purge-resources requires to be empty. */ readProjects?: () => Promise>; /** Block Zero Trust writers in the API until the returned release runs. */ @@ -196,14 +208,17 @@ export type InstallerClient = Pick< | 'createQueue' | 'createR2Bucket' | 'createZone' + | 'deleteKvBulk' | 'deleteKvValue' | 'getWorkerPlainTextBindings' + | 'listKvKeys' | 'listWorkerDomains' | 'listWorkerNames' | 'listWorkerRoutes' | 'listWorkerServiceRoutes' | 'publishWorkerCustomDomainsSafely' | 'publishWorkerRoutesSafely' + | 'putKvBulk' | 'putKvValue' | 'putR2Object' | 'request' diff --git a/packages/void/src/cli/platform-installer.ts b/packages/void/src/cli/platform-installer.ts index bff8cce15..137c822a9 100644 --- a/packages/void/src/cli/platform-installer.ts +++ b/packages/void/src/cli/platform-installer.ts @@ -15,6 +15,7 @@ import { PLATFORM_ADMIN_ASSETS_DIRECTORY, resolvePlatformRuntime } from '@void/p import { join } from 'pathslash'; import { ensurePlatformAccessToken } from './cf-access.ts'; import { getErrorMessage } from './output.ts'; +import { publishInstallationAiLogging } from './platform-ai-logging-projection.ts'; import { initializeInstalledAppAccess, PlatformAppAccessCredentialError, @@ -762,6 +763,15 @@ export async function installPlatform( runParallelPinnedPlatformWrangler, runPinnedPlatformWrangler, }); + // Second pass: the old API may have saved settings to the database only while + // the Workers were uploading. + await publishInstallationAiLogging({ + client: dependencies.client, + accountId: state.plan.account.id, + routingKvId: requireId(ids, 'routing-kv'), + databaseLock: releaseDatabaseLock, + onProgress: dependencies.onProgress, + }); state.manifest = setInstallationEndpoints( state.manifest, diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 174b626ca..862c02f56 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -725,6 +725,7 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise>; + +type AiLoggingListing = { + modes: PlanAiLoggingModes; + publish: { complete: boolean; error?: string } | null; + remaining: number; +}; + type Listing = { catalog: PlatformPlanCatalog; accounts: Array<{ planId: string; count: number }>; + /** Plan AI logging defaults. Platforms before plan defaults do not send it. */ + aiLogging?: AiLoggingListing; }; +const aiLoggingLabels: Record = { + inherit: 'inherit (AI Gateway setting)', + off: 'off', + metadata: 'metadata (no prompts or responses)', + full: 'full (prompts and responses)', +}; + +function planAiLogging(modes: PlanAiLoggingModes | undefined, planId: string): string { + const mode = modes && Object.hasOwn(modes, planId) ? modes[planId] : undefined; + return typeof mode === 'string' ? stripVTControlCharacters(mode) : 'inherit'; +} + +function aiLoggingPending(listing: Listing): boolean { + return Boolean(listing.aiLogging?.publish && !listing.aiLogging.publish.complete); +} + +/** Refuses plan AI logging on a platform whose API would refuse or drop it. */ +function assertPlanAiLoggingSupported(listing: Listing, body: Record): void { + if (body.aiLogging !== undefined && !listing.aiLogging) { + throw new Error( + 'platform: This platform does not support plan AI logging defaults. Upgrade the platform first with `void platform upgrade`.', + ); + } +} + const limitLabels: Record<(typeof PLAN_EDITABLE_LIMIT_KEYS)[number], string> = { requestsPerMonth: 'Requests per billing month', cpuMsPerRequest: 'Default CPU per request (ms)', @@ -56,7 +97,11 @@ function writePlanDiff( from: PlatformPlanDefinition, to: PlatformPlanDefinition, write: (line: string) => void, + aiLogging?: Readonly<{ from: string; to: string }>, ): void { + if (aiLogging && aiLogging.from !== aiLogging.to) { + write(`AI Gateway logging: ${aiLogging.from} → ${aiLogging.to}`); + } if (from.buildInstanceType !== to.buildInstanceType) { write(`Build size: ${from.buildInstanceType} → ${to.buildInstanceType}`); } @@ -87,16 +132,36 @@ function showListing(listing: Listing, json: boolean): void { if (listing.catalog.reconciliation && !listing.catalog.reconciliation.complete) { console.log('Account updates are pending. Run void platform config plans sync --yes.'); } + if (aiLoggingPending(listing)) { + console.log( + `AI logging publish is pending for ${listing.aiLogging!.remaining} projects. Run void platform config plans sync --yes.`, + ); + } } function showPreview( preview: Record, before: PlatformPlanCatalog, + beforeAiLogging: PlanAiLoggingModes | undefined, write = console.error, ): void { const after = preview.catalog as PlatformPlanCatalog; + const change = preview.aiLogging as + | { modes?: PlanAiLoggingModes; modesBefore?: PlanAiLoggingModes } + | undefined; + const afterAiLogging = change?.modes; + // The preview reads the saved defaults under the catalog lock; the listing may be cached. + const savedAiLogging = change?.modesBefore ?? beforeAiLogging; + const aiLogging = (from: string, to: string) => + afterAiLogging + ? { from: planAiLogging(savedAiLogging, from), to: planAiLogging(afterAiLogging, to) } + : undefined; write(`Change: ${preview.action} ${preview.target}`); write(`Accounts affected: ${preview.affectedAccounts}`); + const publish = preview.aiLoggingPublish as { remaining?: unknown } | undefined; + if (typeof publish?.remaining === 'number' && publish.remaining > 0) { + write(`AI logging settings to publish: ${publish.remaining} projects`); + } for (const warning of (preview.warnings ?? []) as Array) { write(stripVTControlCharacters(warning)); } @@ -114,6 +179,9 @@ function showPreview( write(` ${limitLabels[key]}: ${limitValue(plan.limits[key], key)}`); } write(` Short project slugs: ${shortSlugsLabel(plan.allowShortSlugs)}`); + if (afterAiLogging) { + write(` AI Gateway logging: ${planAiLogging(afterAiLogging, plan.id)}`); + } continue; } if (previous.name !== plan.name) { @@ -122,7 +190,7 @@ function showPreview( if (previous.archived !== plan.archived) { write(`${plan.id}: ${plan.archived ? 'archive for new assignments' : 'restore assignments'}`); } - writePlanDiff(previous, plan, write); + writePlanDiff(previous, plan, write, aiLogging(plan.id, plan.id)); } for (const plan of before.plans) { if (!after.plans.some((entry) => entry.id === plan.id)) { @@ -130,7 +198,7 @@ function showPreview( const replacement = after.plans.find((entry) => entry.id === preview.replacementPlanId); if (replacement) { write(`Move accounts to ${label(replacement)}`); - writePlanDiff(plan, replacement, write); + writePlanDiff(plan, replacement, write, aiLogging(plan.id, replacement.id)); } } } @@ -182,6 +250,7 @@ async function choosePlan( async function changesInput( args: PlatformPlansArgs, plan?: PlatformPlanDefinition, + listing?: Listing, ): Promise> { if (args.file) { const input: unknown = JSON.parse(await readFile(args.file, 'utf8')); @@ -190,12 +259,17 @@ async function changesInput( } const object = input as Record; for (const key of Object.keys(object)) { - if (!['name', 'limits', 'buildInstanceType', 'allowShortSlugs'].includes(key)) { + if (!['name', 'limits', 'buildInstanceType', 'allowShortSlugs', 'aiLogging'].includes(key)) { throw new Error( - `platform: Unknown plan field ${key}. Use name, limits, buildInstanceType, or allowShortSlugs.`, + `platform: Unknown plan field ${key}. Use name, limits, buildInstanceType, allowShortSlugs, or aiLogging.`, ); } } + try { + parsePlanAiLoggingInput(object.aiLogging); + } catch (error) { + throw new Error(`platform: ${(error as Error).message}`); + } if (object.allowShortSlugs !== undefined && typeof object.allowShortSlugs !== 'boolean') { throw new Error('platform: allowShortSlugs must be a boolean.'); } @@ -235,9 +309,28 @@ async function changesInput( label: `${limitLabels[key]}: ${limitValue(plan!.limits[key], key)}`, })), { value: 'buildInstanceType', label: 'Build instance size' }, + ...(listing?.aiLogging + ? [ + { + value: 'aiLogging', + label: `AI Gateway logging: ${planAiLogging(listing.aiLogging.modes, plan!.id)}`, + }, + ] + : []), ], }), ); + if (field === 'aiLogging') { + return { + aiLogging: value( + await select({ + message: 'AI Gateway logging for projects on this plan without a project setting:', + initialValue: planAiLogging(listing!.aiLogging!.modes, plan!.id), + options: AI_LOGGING_MODES.map((mode) => ({ value: mode, label: aiLoggingLabels[mode] })), + }), + ), + }; + } if (field === 'name') { return { name: parsePlanName(value(await text({ message: 'Plan name:', initialValue: plan!.name }))), @@ -276,8 +369,10 @@ async function apply( path: string, body: Record, args: PlatformPlansArgs, - catalog: PlatformPlanCatalog, + listing: Listing, ): Promise { + const { catalog } = listing; + assertPlanAiLoggingSupported(listing, body); if (!args.plan && !args.yes && (!process.stdin.isTTY || args.json)) { throw new Error( 'platform: Configuration changes require --yes or --plan in non-interactive mode.', @@ -288,7 +383,7 @@ async function apply( if (args.json) { print(preview); } else { - showPreview(preview, catalog, console.log); + showPreview(preview, catalog, listing.aiLogging?.modes, console.log); } return; } @@ -296,7 +391,7 @@ async function apply( throw new Error('platform: The platform returned an invalid plan revision.'); } if (!args.yes) { - showPreview(preview, catalog); + showPreview(preview, catalog, listing.aiLogging?.modes); if ( !value( await confirm({ message: `Apply this change to ${client.origin}?`, initialValue: false }), @@ -305,10 +400,7 @@ async function apply( throw new Error('platform: Cancelled.'); } } - let result = await client.request(path, { - method: 'POST', - body: { ...body, expectedRevision: preview.expectedRevision }, - }); + let result = await save(client, path, { ...body, expectedRevision: preview.expectedRevision }); const committedCatalog = result.catalog as PlatformPlanCatalog; if (!Number.isSafeInteger(committedCatalog?.revision)) { throw new Error( @@ -318,36 +410,43 @@ async function apply( const committedRevision = committedCatalog.revision; const applied = result; let previousCursor = catalog.reconciliation?.cursor ?? null; + let published = 0; + let expectedBy: string | null = null; while (true) { - const reconciliation = result.reconciliation as - | { complete?: boolean; processed?: number; error?: string } - | undefined; - if (!reconciliation || reconciliation.complete !== false) { + const reconciliation = result.reconciliation as Progress | undefined; + const aiLogging = result.aiLoggingPublish as Progress | undefined; + if (aiLogging && Number.isSafeInteger(aiLogging.processed)) { + published += aiLogging.processed!; + } + if (typeof aiLogging?.expectedBy === 'string') { + expectedBy = aiLogging.expectedBy; + } + const accountsPending = reconciliation?.complete === false; + if (!accountsPending && aiLogging?.complete !== false && !aiLogging?.error) { break; } const currentCatalog = result.catalog as PlatformPlanCatalog; const cursor = currentCatalog.reconciliation?.cursor ?? null; - if ( - reconciliation.error || - !Number.isSafeInteger(reconciliation.processed) || - reconciliation.processed! <= 0 || - cursor === previousCursor || - currentCatalog.revision !== committedRevision - ) { + const accountsStuck = + accountsPending && (!progressed(reconciliation) || cursor === previousCursor); + const aiLoggingStuck = + aiLogging?.complete === false ? !progressed(aiLogging) : !!aiLogging?.error; + if (accountsStuck || aiLoggingStuck || currentCatalog.revision !== committedRevision) { const pending = { ...applied, ...result }; print(pending); throw new OperatorClientError( - 'Plan configuration was saved, but account updates are pending. Run void platform config plans sync --yes.', + accountsStuck || !aiLoggingStuck + ? 'Plan configuration was saved, but account updates are pending. Run void platform config plans sync --yes.' + : 'Plan configuration was saved, but the AI logging setting is not published to every project yet. Run void platform config plans sync --yes.', 'plan_sync_pending', undefined, pending, ); } - previousCursor = cursor; - result = await client.request('/config/plans/sync', { - method: 'POST', - body: { expectedRevision: committedRevision }, - }); + if (accountsPending) { + previousCursor = cursor; + } + result = await save(client, '/config/plans/sync', { expectedRevision: committedRevision }); } const completed = { ...applied, ...result }; if (args.json) { @@ -356,9 +455,58 @@ async function apply( printSuccess( `Saved ${applied.action ?? 'plans.sync'} ${applied.target ?? ''}. Account updates complete.`, ); + const change = preview.aiLogging as + | { planIds?: Array; includeUnknown?: boolean } + | undefined; + if (published > 0 || change?.planIds?.length || change?.includeUnknown) { + const by = utcTime(expectedBy); + console.log( + `AI logging published to ${published} projects${by ? `; expected by ${by} (normally within 2 minutes)` : ''}.`, + ); + } } } +/** + * Send a plan change or sync page. The API answers `ok: false` when the catalog saved but an + * account or AI logging page failed; that body still carries the saved catalog, so return it and + * let the caller name the resumption command instead of a generic partial failure. + */ +async function save( + client: OperatorClient, + path: string, + body: Record, +): Promise> { + try { + return await client.request(path, { method: 'POST', body }); + } catch (error) { + const details = error instanceof OperatorClientError ? error.details : undefined; + if ( + error instanceof OperatorClientError && + error.code === 'partial_failure' && + details && + typeof details === 'object' && + 'catalog' in details + ) { + return details as Record; + } + throw error; + } +} + +type Progress = { + complete?: boolean; + processed?: number; + remaining?: number; + expectedBy?: string | null; + error?: unknown; +}; + +/** A sync step that wrote something and reported no error. */ +function progressed(progress: Progress): boolean { + return !progress.error && Number.isSafeInteger(progress.processed) && progress.processed! > 0; +} + export async function runPlatformPlansConfig(args: PlatformPlansArgs): Promise { const origin = resolveOperatorOrigin(args.connection); const client = new OperatorClient(origin, readOperatorToken(origin)); @@ -380,8 +528,9 @@ export async function runPlatformPlansConfig(args: PlatformPlansArgs): Promise ({ read: vi.fn(), write: vi.fn(), remove: vi.fn() })); const mutationPrompt = vi.hoisted(() => ({ question: vi.fn(), close: vi.fn() })); @@ -1782,3 +1783,207 @@ describe('operator transport and execution', () => { expect(write.mock.calls.map(([line]) => String(line)).join('\n')).toContain('historical row'); }); }); + +describe('project AI logging commands', () => { + const resolution = { + mode: 'off', + planId: 'pro', + planKnown: true, + defaultPlanId: 'free', + planMode: null, + effective: 'off', + source: 'project', + }; + + /** Serves discovery at `platformVersion` (or a status) and operator API replies in order. */ + function serve( + platformVersion: string | number, + replies: Array | Response>, + ): ReturnType> { + vi.stubEnv('VOID_API_URL', 'https://one.example'); + vi.stubEnv('VOID_OPERATOR_TOKEN', 'operator'); + const queue = [...replies]; + const fetcher = vi.fn().mockImplementation(async (input) => { + const url = new URL(String(input)); + if (url.pathname === '/.well-known/void-platform') { + return typeof platformVersion === 'number' + ? new Response(null, { status: platformVersion }) + : platformDiscoveryResponse(url.origin, platformVersion); + } + const reply = queue.shift() ?? { error: 'unexpected_request' }; + return reply instanceof Response ? reply : Response.json(reply); + }); + vi.stubGlobal('fetch', fetcher); + return fetcher; + } + + function printed(log: { mock: { calls: Array> } }): string { + return log.mock.calls.map(([line]) => String(line)).join('\n'); + } + + it('prints the source, plan default, and expected time after a change', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const fetcher = serve('1.9.0', [ + { dryRun: true, action: 'project.ai-logging', target: 'p1', ...resolution, previous: 'full' }, + { ...resolution, previous: 'full', expectedBy: '2026-11-13T14:03:20.000Z' }, + ]); + await runOperatorCommand(parseOperatorArgs(['project', 'ai-logging', 'p1', 'off', '--yes'])); + expect(printed(log)).toBe( + [ + 'AI logging for p1: off (set on project; plan "pro" default: inherit)', + 'New AI requests are expected to follow it by 14:03:20 UTC (normally within 2 minutes).', + ].join('\n'), + ); + expect(fetcher.mock.calls.map(([input]) => new URL(String(input)).pathname)).toEqual([ + '/.well-known/void-platform', + '/operator/v1/projects/p1/ai-logging', + '/operator/v1/projects/p1/ai-logging', + ]); + }); + + it('shows a plan default from an unknown plan ID in the dry run', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + serve('hosted', [ + { + dryRun: true, + action: 'project.ai-logging', + target: 'p1', + ...resolution, + mode: 'inherit', + planId: 'legacy-x', + planKnown: false, + planMode: 'metadata', + effective: 'metadata', + source: 'plan', + previous: 'off', + }, + ]); + await runOperatorCommand( + parseOperatorArgs(['project', 'ai-logging', 'p1', 'inherit', '--plan']), + ); + expect(printed(log)).toBe( + [ + 'AI logging for p1: off → metadata (from plan "legacy-x" (not in catalog; uses default plan "free"))', + 'No change was made. Run the command again without --plan to apply it.', + ].join('\n'), + ); + }); + + it('keeps the JSON reply unchanged', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const reply = { ...resolution, previous: 'full', expectedBy: '2026-11-13T14:03:20.000Z' }; + serve('1.9.0', [ + { dryRun: true, action: 'project.ai-logging', target: 'p1', ...resolution, previous: 'full' }, + reply, + ]); + await runOperatorCommand( + parseOperatorArgs(['project', 'ai-logging', 'p1', 'off', '--yes', '--json']), + ); + expect(log).toHaveBeenCalledExactlyOnceWith(JSON.stringify(reply)); + }); + + it.each([ + ['1.7.2', '1.7.2'], + ['1.8.0-beta.1', '1.8.0-beta.1'], + ['a platform without discovery', 404], + ])('refuses before any request on %s', async (_name, version) => { + const fetcher = serve(version, []); + await expect( + runOperatorCommand(parseOperatorArgs(['project', 'ai-logging', 'p1', 'off', '--yes'])), + ).rejects.toMatchObject({ + code: 'unsupported', + message: 'This platform does not support AI logging policies. Run `void platform upgrade`.', + }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('lets the request report its own error when the version cannot be read', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const fetcher = serve(503, [ + Response.json({ error: 'not_found', message: 'Project not found.' }, { status: 404 }), + ]); + await expect( + runOperatorCommand(parseOperatorArgs(['project', 'ai-logging', 'p1', 'off', '--yes'])), + ).rejects.toThrow('Project not found.'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('prints a 1.8.0 reply that has only the saved mode', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + serve('1.8.0', [ + { dryRun: true, action: 'project.ai-logging', target: 'p1', mode: 'off' }, + { mode: 'off' }, + ]); + await runOperatorCommand(parseOperatorArgs(['project', 'ai-logging', 'p1', 'off', '--yes'])); + expect(printed(log)).toBe('AI logging for p1: off'); + }); + + it.each([ + ['applied', {}, 'AI Gateway logging: metadata (from plan "pro") — applied'], + [ + 'pending', + { publishedAt: '2026-11-13T14:01:20.000Z' }, + 'AI Gateway logging: metadata (from plan "pro") — pending (expected by 14:03:20 UTC)', + ], + [ + 'pending_plan_publish', + {}, + 'AI Gateway logging: metadata (from plan "pro") — pending (the plan default is still being published)', + ], + [ + 'drift', + { published: 'full' }, + 'AI Gateway logging: metadata (from plan "pro") — drift (published: full)', + ], + [ + 'unknown', + {}, + 'AI Gateway logging: metadata (from plan "pro") — state unknown (the published setting could not be read)', + ], + ])( + 'summarizes the effective setting and %s state in project show', + async (state, extra, line) => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const aiLogging = { + ...resolution, + mode: 'inherit', + planMode: 'metadata', + effective: 'metadata', + source: 'plan', + published: 'metadata', + publishedAt: null, + state, + ...extra, + }; + serve('1.9.0', [{ project: { id: 'p1', slug: 'app' }, aiLogging }]); + await runOperatorCommand(parseOperatorArgs(['project', 'show', 'p1'])); + const output = printed(log); + expect(output).toContain('Project:\n ID: p1\n Slug: app'); + expect(output.split('\n').at(-1)).toBe(line); + expect(output).not.toContain('Ai logging'); + }, + ); + + it('keeps every AI logging field in project show JSON', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const reply = { project: { id: 'p1' }, aiLogging: { ...resolution, state: 'applied' } }; + serve('1.9.0', [reply]); + await runOperatorCommand(parseOperatorArgs(['project', 'show', 'p1', '--json'])); + expect(log).toHaveBeenCalledExactlyOnceWith(JSON.stringify(reply)); + }); + + it('documents the modes and timing in help', () => { + const command = operatorCommands.find( + (entry) => entry.group === 'project' && entry.action === 'ai-logging', + )!; + expect(command.usage).toEqual([ + 'void platform project ai-logging [options]', + ]); + expect(command.description).toContain('normally follow it within 2 minutes'); + expect(() => parseOperatorArgs(['project', 'ai-logging', 'p1'])).toThrow( + 'Usage: void platform project ai-logging .', + ); + }); +}); diff --git a/packages/void/test/unit/platform-ai-logging-projection.test.ts b/packages/void/test/unit/platform-ai-logging-projection.test.ts new file mode 100644 index 000000000..f16b23dd5 --- /dev/null +++ b/packages/void/test/unit/platform-ai-logging-projection.test.ts @@ -0,0 +1,373 @@ +import { PGlite } from '@electric-sql/pglite'; +import { + AI_LOGGING_CLEARED_TTL_SECONDS, + AI_LOGGING_ORPHAN_GRACE_MS, +} from '@void/platform/ai-logging'; +import { getPlatformRuntimeDirectory, readPlatformRuntimeManifest } from '@void/platform/installer'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + projectPlatformAiLoggingConnection, + publishInstallationAiLogging, + type AiLoggingProjectionKv, +} from '../../src/cli/platform-ai-logging-projection.ts'; +import type { CloudflareKvBulkEntry } from '../../src/cli/platform-cloudflare.ts'; +import { preparePlatformDatabaseConnection } from '../../src/cli/platform-installer-database.ts'; +import type { QueryConnection } from '../../src/cli/platform-zero-trust-uninstall.ts'; + +const NOW = 1_800_000_000_000; + +let database: PGlite; +let connection: QueryConnection; + +/** An in-memory ROUTING namespace that records every bulk call. */ +function memoryKv(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + const calls: Array = []; + const expirations = new Map(); + const kv: AiLoggingProjectionKv = { + list: async (prefix) => { + calls.push('list'); + return [...store] + .filter(([name]) => name.startsWith(prefix)) + .map(([name, entry]) => + entry.metadata === undefined ? { name } : { name, metadata: entry.metadata }, + ); + }, + put: async (entries: ReadonlyArray) => { + calls.push('put'); + for (const entry of entries) { + store.set(entry.key, { value: entry.value, metadata: entry.metadata }); + if (entry.expiration_ttl !== undefined) { + expirations.set(entry.key, entry.expiration_ttl); + } + } + }, + delete: async (keys) => { + calls.push('delete'); + for (const key of keys) { + store.delete(key); + } + }, + }; + return { kv, store, calls, expirations }; +} + +/** Records each statement so tests can check lock and transaction order. */ +function recording(statements: Array, onStatement?: (sql: string) => void) { + return { + query: async (sql: string, values?: ReadonlyArray) => { + statements.push(sql); + onStatement?.(sql); + return connection.query(sql, values); + }, + } as QueryConnection; +} + +async function seed(sql: string) { + await database.exec(sql); +} + +beforeAll(async () => { + database = new PGlite(); + await database.waitReady; + connection = database as unknown as QueryConnection; + const runtime = readPlatformRuntimeManifest(); + await preparePlatformDatabaseConnection( + connection as Parameters[0], + getPlatformRuntimeDirectory(), + runtime.database, + null, + false, + ); +}, 60_000); + +afterAll(async () => { + await database.close(); +}); + +beforeEach(async () => { + await seed(`DELETE FROM project_ai_logging; DELETE FROM projects; DELETE FROM users; + DELETE FROM platform_settings WHERE key IN ('plans:catalog', 'plans:ai-logging');`); +}); + +describe('installer AI logging projection', () => { + it('skips an older schema without the AI logging table', async () => { + const empty = new PGlite(); + await empty.waitReady; + try { + const { kv, calls } = memoryKv(); + await expect( + projectPlatformAiLoggingConnection(empty as unknown as QueryConnection, kv), + ).resolves.toEqual({ skipped: true }); + expect(calls).toEqual([]); + } finally { + await empty.close(); + } + }); + + it('publishes overrides and plan defaults with metadata and deletes stale keys', async () => { + await seed(` + INSERT INTO users (id, login, provider_id, plan) VALUES + ('u-pro', 'pro', '1', 'pro'), ('u-free', 'free', '2', 'free'), + ('u-legacy', 'legacy', '3', 'legacy-x'); + INSERT INTO projects (id, user_id, slug) VALUES + ('p-override', 'u-free', 'override'), ('p-plan', 'u-pro', 'plan'), + ('p-inherit', 'u-free', 'inherit'), ('p-stale', 'u-free', 'stale'), + ('p-legacy', 'u-legacy', 'legacy'), ('p-wins', 'u-pro', 'wins'), + ('p-deleting', 'u-free', 'deleting'); + UPDATE projects SET status = 'deleting' WHERE id = 'p-deleting'; + INSERT INTO project_ai_logging (project_id, mode) VALUES + ('p-override', 'off'), ('p-wins', 'full'), ('p-deleting', 'off'); + INSERT INTO platform_settings (key, value) VALUES + ('plans:ai-logging', '{"modes":{"pro":"metadata","custom":"off"},"publish":null}'); + `); + const { kv, store, expirations } = memoryKv({ + 'ai-logging:p-stale': { value: 'off', metadata: { mode: 'off', at: 1 } }, + 'ai-logging:p-plan': { value: 'full', metadata: { mode: 'full', at: 1 } }, + 'ai-logging:p-gone-old': { value: 'off', metadata: { mode: 'off', at: 1 } }, + 'ai-logging:p-gone-bare': { value: 'off' }, + 'ai-logging:p-just-created': { + value: 'off', + metadata: { mode: 'off', at: NOW - AI_LOGGING_ORPHAN_GRACE_MS + 1_000 }, + }, + 'ai-block:p-plan': { value: '1' }, + }); + const progress: Array = []; + + const result = await projectPlatformAiLoggingConnection(connection, kv, { + now: () => NOW, + onProgress: (message) => progress.push(message), + }); + + expect(result).toEqual({ skipped: false, projects: 6, written: 4, deleted: 3 }); + expect(progress).toEqual(['Publishing AI logging settings (6 projects)']); + expect(Object.fromEntries(store)).toEqual({ + 'ai-logging:p-override': { value: 'off', metadata: { mode: 'off', at: NOW } }, + 'ai-logging:p-plan': { value: 'metadata', metadata: { mode: 'metadata', at: NOW } }, + 'ai-logging:p-wins': { value: 'full', metadata: { mode: 'full', at: NOW } }, + // A plan ID missing from the catalog follows the default plan (custom on installs). + 'ai-logging:p-legacy': { value: 'off', metadata: { mode: 'off', at: NOW } }, + // Too young to call an orphan: its create transaction may still commit. + 'ai-logging:p-just-created': { + value: 'off', + metadata: { mode: 'off', at: NOW - AI_LOGGING_ORPHAN_GRACE_MS + 1_000 }, + }, + 'ai-block:p-plan': { value: '1' }, + // A project that now inherits leaves a short-lived marker so readback says pending. + 'ai-logging-cleared:p-stale': { value: 'inherit', metadata: { at: NOW } }, + }); + expect(Object.fromEntries(expirations)).toEqual({ + 'ai-logging-cleared:p-stale': AI_LOGGING_CLEARED_TTL_SECONDS, + }); + }); + + it('stamps keys with the database clock, not the operator machine clock', async () => { + await seed(` + INSERT INTO users (id, login, plan) VALUES ('u1', 'one', 'free'); + INSERT INTO projects (id, user_id, slug) VALUES ('p1', 'u1', 'one'); + INSERT INTO project_ai_logging (project_id, mode) VALUES ('p1', 'off'); + `); + // An orphan the API wrote 5 minutes ago by its own clock: inside the grace period. + const { kv, store } = memoryKv({ + 'ai-logging:p-young': { value: 'off', metadata: { mode: 'off', at: NOW - 300_000 } }, + }); + const databaseClock = { + query: async (sql: string, values?: ReadonlyArray) => + sql.includes('clock_timestamp()') + ? { rows: [{ now: NOW }] } + : connection.query(sql, values), + } as QueryConnection; + // The operator machine runs an hour fast. + vi.spyOn(Date, 'now').mockReturnValue(NOW + 3_600_000); + try { + await projectPlatformAiLoggingConnection(databaseClock, kv); + } finally { + vi.mocked(Date.now).mockRestore(); + } + expect(Object.fromEntries(store)).toEqual({ + 'ai-logging:p1': { value: 'off', metadata: { mode: 'off', at: NOW } }, + 'ai-logging:p-young': { value: 'off', metadata: { mode: 'off', at: NOW - 300_000 } }, + }); + }); + + it('reads the clock from a real database', async () => { + await seed(` + INSERT INTO users (id, login, plan) VALUES ('u1', 'one', 'free'); + INSERT INTO projects (id, user_id, slug) VALUES ('p1', 'u1', 'one'); + INSERT INTO project_ai_logging (project_id, mode) VALUES ('p1', 'off'); + `); + const { kv, store } = memoryKv(); + const before = Date.now(); + await projectPlatformAiLoggingConnection(connection, kv); + const { at } = store.get('ai-logging:p1')!.metadata as { at: number }; + expect(Number.isSafeInteger(at)).toBe(true); + expect(at).toBeGreaterThanOrEqual(before - 60_000); + expect(at).toBeLessThanOrEqual(Date.now() + 60_000); + }); + + it('holds the project row locks while it reads inputs and writes KV', async () => { + await seed(` + INSERT INTO users (id, login, plan) VALUES ('u1', 'one', 'free'); + INSERT INTO projects (id, user_id, slug) VALUES ('p1', 'u1', 'one'), ('p2', 'u1', 'two'); + INSERT INTO project_ai_logging (project_id, mode) VALUES ('p1', 'metadata'); + `); + const statements: Array = []; + const { kv, calls } = memoryKv(); + const order: Array = []; + const wrapped: AiLoggingProjectionKv = { + list: async (prefix) => { + order.push('kv list'); + return kv.list(prefix); + }, + put: async (entries) => { + order.push('kv put'); + // The change the API saved while the projection waited for the lock is the + // one written, because the inputs are read again under the lock. + expect(entries).toEqual([ + { key: 'ai-logging:p1', value: 'off', metadata: { mode: 'off', at: NOW } }, + ]); + return kv.put(entries); + }, + delete: kv.delete, + }; + const client = recording(statements, (sql) => { + if (sql.includes('FOR NO KEY UPDATE')) { + order.push('lock'); + } else if (sql === 'COMMIT') { + order.push('commit'); + } + }); + // Simulate the API saving a newer override just before the lock is granted. + const lockingClient = { + query: async (sql: string, values?: ReadonlyArray) => { + if (sql.includes('FOR NO KEY UPDATE')) { + await connection.query( + `UPDATE project_ai_logging SET mode = 'off' WHERE project_id = 'p1'`, + ); + } + return client.query(sql, values); + }, + } as QueryConnection; + + await projectPlatformAiLoggingConnection(lockingClient, wrapped, { now: () => NOW }); + + expect(order).toEqual(['kv list', 'lock', 'kv put', 'commit']); + const lock = statements.find((sql) => sql.includes('FOR NO KEY UPDATE'))!; + expect(lock).toContain('FROM "projects"'); + expect(lock).toContain('ORDER BY "id"'); + expect(statements[statements.indexOf('BEGIN') + 1]).toContain('lock_timeout'); + expect(calls).toEqual(['list', 'put']); + }); + + it('reads plan defaults again once the project row locks are held', async () => { + await seed(` + INSERT INTO users (id, login, plan) VALUES ('u1', 'one', 'pro'); + INSERT INTO projects (id, user_id, slug) VALUES ('p1', 'u1', 'one'); + INSERT INTO platform_settings (key, value) VALUES + ('plans:ai-logging', '{"modes":{"pro":"metadata"},"publish":null}'); + `); + const { kv, store } = memoryKv(); + // Simulate the API saving a newer plan default just before the lock is granted. + const lockingClient = { + query: async (sql: string, values?: ReadonlyArray) => { + if (sql.includes('FOR NO KEY UPDATE')) { + await connection.query( + `UPDATE platform_settings SET value = '{"modes":{"pro":"off"},"publish":null}' + WHERE key = 'plans:ai-logging'`, + ); + } + return connection.query(sql, values); + }, + } as QueryConnection; + + await projectPlatformAiLoggingConnection(lockingClient, kv, { now: () => NOW }); + + expect(Object.fromEntries(store)).toEqual({ + 'ai-logging:p1': { value: 'off', metadata: { mode: 'off', at: NOW } }, + }); + }); + + it('rolls back and fails when a bulk write fails', async () => { + await seed(` + INSERT INTO users (id, login, plan) VALUES ('u1', 'one', 'free'); + INSERT INTO projects (id, user_id, slug) VALUES ('p1', 'u1', 'one'); + INSERT INTO project_ai_logging (project_id, mode) VALUES ('p1', 'off'); + `); + const statements: Array = []; + const { kv } = memoryKv(); + const failing = { + ...kv, + put: vi.fn(async () => { + throw new Error('KV down'); + }), + }; + await expect( + projectPlatformAiLoggingConnection(recording(statements), failing), + ).rejects.toThrow('KV down'); + expect(statements.at(-1)).toBe('ROLLBACK'); + expect(statements).not.toContain('COMMIT'); + }); + + it('changes nothing visible when run again', async () => { + await seed(` + INSERT INTO users (id, login, plan) VALUES ('u1', 'one', 'free'); + INSERT INTO projects (id, user_id, slug) VALUES ('p1', 'u1', 'one'), ('p2', 'u1', 'two'); + INSERT INTO project_ai_logging (project_id, mode) VALUES ('p1', 'full'); + `); + const { kv, store } = memoryKv(); + await projectPlatformAiLoggingConnection(connection, kv, { now: () => NOW }); + const first = Object.fromEntries(store); + await expect( + projectPlatformAiLoggingConnection(connection, kv, { now: () => NOW }), + ).resolves.toEqual({ skipped: false, projects: 1, written: 1, deleted: 0 }); + expect(Object.fromEntries(store)).toEqual(first); + // Inherit projects never get a key. + expect(store.has('ai-logging:p2')).toBe(false); + }); + + it('refuses to publish from invalid saved plan defaults', async () => { + await seed(`INSERT INTO platform_settings (key, value) + VALUES ('plans:ai-logging', '{"modes":{"pro":"loud"}}');`); + const { kv, calls } = memoryKv(); + await expect(projectPlatformAiLoggingConnection(connection, kv)).rejects.toThrow( + 'The saved plan settings are not valid for this platform version', + ); + expect(calls).toEqual(['list']); + }); + + it('runs through the database lock with a KV client bound to the routing namespace', async () => { + const client = { + listKvKeys: vi.fn(async () => []), + putKvBulk: vi.fn(async () => {}), + deleteKvBulk: vi.fn(async () => {}), + }; + const progress: Array = []; + const publishAiLogging = vi.fn(async (kv: AiLoggingProjectionKv) => { + await kv.list('ai-logging:'); + await kv.put([{ key: 'ai-logging:p1', value: 'off' }]); + await kv.delete(['ai-logging:p2']); + return { skipped: false as const, projects: 2, written: 1, deleted: 1 }; + }); + await publishInstallationAiLogging({ + client, + accountId: 'account', + routingKvId: 'routing', + databaseLock: { publishAiLogging }, + onProgress: (message) => progress.push(message), + }); + expect(progress).toEqual(['Publishing AI logging settings']); + expect(client.listKvKeys).toHaveBeenCalledWith('account', 'routing', 'ai-logging:'); + expect(client.putKvBulk).toHaveBeenCalledWith('account', 'routing', [ + { key: 'ai-logging:p1', value: 'off' }, + ]); + expect(client.deleteKvBulk).toHaveBeenCalledWith('account', 'routing', ['ai-logging:p2']); + await expect( + publishInstallationAiLogging({ + client, + accountId: 'account', + routingKvId: 'routing', + databaseLock: {}, + }), + ).resolves.toBeNull(); + }); +}); diff --git a/packages/void/test/unit/platform-cloudflare.test.ts b/packages/void/test/unit/platform-cloudflare.test.ts index b35515604..ee757d202 100644 --- a/packages/void/test/unit/platform-cloudflare.test.ts +++ b/packages/void/test/unit/platform-cloudflare.test.ts @@ -807,6 +807,89 @@ describe('platform Cloudflare client', () => { }); }); + it('lists KV keys with metadata across cursor pages', async () => { + const requests: Array = []; + const client = new PlatformCloudflareClient('token', { + apiBase: 'https://cloudflare.example.test/client/v4', + fetcher: async (input) => { + const url = new URL(String(input)); + requests.push(url); + const first = !url.searchParams.has('cursor'); + return Response.json({ + success: true, + result: first + ? [{ name: 'ai-logging:p1', metadata: { mode: 'off', at: 1 } }] + : [{ name: 'ai-logging:p2' }], + result_info: { count: 1, cursor: first ? 'next' : '' }, + }); + }, + }); + await expect(client.listKvKeys('account', 'routing', 'ai-logging:')).resolves.toEqual([ + { name: 'ai-logging:p1', metadata: { mode: 'off', at: 1 } }, + { name: 'ai-logging:p2' }, + ]); + expect(requests.map((url) => url.pathname)).toEqual([ + '/client/v4/accounts/account/storage/kv/namespaces/routing/keys', + '/client/v4/accounts/account/storage/kv/namespaces/routing/keys', + ]); + expect(requests.map((url) => Object.fromEntries(url.searchParams))).toEqual([ + { prefix: 'ai-logging:', limit: '1000' }, + { prefix: 'ai-logging:', limit: '1000', cursor: 'next' }, + ]); + }); + + it('stops on a repeated KV key cursor instead of looping', async () => { + const client = new PlatformCloudflareClient('token', { + fetcher: async () => + Response.json({ success: true, result: [], result_info: { cursor: 'same' } }), + }); + await expect(client.listKvKeys('account', 'routing', 'ai-logging:')).rejects.toThrow( + 'repeated KV key cursor', + ); + }); + + it('writes and deletes KV keys in bulk batches and fails on any unwritten key', async () => { + const requests: Array<{ method: string; path: string; body: Array }> = []; + let failed: Array = []; + const client = new PlatformCloudflareClient('token', { + apiBase: 'https://cloudflare.example.test/client/v4', + fetcher: async (input, init) => { + const body = JSON.parse(String(init?.body)) as Array; + requests.push({ + method: init?.method ?? 'GET', + path: new URL(String(input)).pathname, + body, + }); + return Response.json({ + success: true, + result: { successful_key_count: body.length, unsuccessful_keys: failed }, + }); + }, + }); + const entries = Array.from({ length: 10_001 }, (_, index) => ({ + key: `ai-logging:p${index}`, + value: 'off', + metadata: { mode: 'off', at: 1 }, + })); + await client.putKvBulk('account', 'routing', entries); + await client.deleteKvBulk('account', 'routing', ['ai-logging:gone']); + expect(requests.map(({ method, path, body }) => [method, path, body.length])).toEqual([ + ['PUT', '/client/v4/accounts/account/storage/kv/namespaces/routing/bulk', 10_000], + ['PUT', '/client/v4/accounts/account/storage/kv/namespaces/routing/bulk', 1], + ['POST', '/client/v4/accounts/account/storage/kv/namespaces/routing/bulk/delete', 1], + ]); + expect(requests[0]!.body[0]).toEqual(entries[0]); + expect(requests[2]!.body).toEqual(['ai-logging:gone']); + + failed = ['ai-logging:p1']; + await expect(client.putKvBulk('account', 'routing', entries.slice(0, 2))).rejects.toThrow( + 'did not write 1 KV keys', + ); + await expect(client.deleteKvBulk('account', 'routing', ['ai-logging:p1'])).rejects.toThrow( + 'did not delete 1 KV keys', + ); + }); + it('filters KV namespaces by exact title instead of trusting an unsupported API filter', async () => { const entries = [ { id: 'routing-id', title: 'void-example-routing' }, diff --git a/packages/void/test/unit/platform-deployment-execution.test.ts b/packages/void/test/unit/platform-deployment-execution.test.ts index 0338410b7..89b30d2fd 100644 --- a/packages/void/test/unit/platform-deployment-execution.test.ts +++ b/packages/void/test/unit/platform-deployment-execution.test.ts @@ -185,14 +185,17 @@ it.each([1, 0])( createQueue: provision, createR2Bucket: provision, createDispatchNamespace: provision, + deleteKvBulk: provision, deleteKvValue: provision, getWorkerPlainTextBindings: provision, + listKvKeys: provision, listWorkerDomains: provision, listWorkerNames: provision, listWorkerRoutes: provision, listWorkerServiceRoutes: provision, publishWorkerCustomDomainsSafely: provision, publishWorkerRoutesSafely: provision, + putKvBulk: provision, putKvValue: async () => {}, putR2Object: async () => {}, request: provision, diff --git a/packages/void/test/unit/platform-install-cutover.test.ts b/packages/void/test/unit/platform-install-cutover.test.ts index 8bc9be0f4..5775a7b70 100644 --- a/packages/void/test/unit/platform-install-cutover.test.ts +++ b/packages/void/test/unit/platform-install-cutover.test.ts @@ -254,6 +254,11 @@ async function createFakeCloudflare() { createZone: async (): Promise => { throw new Error('No zone expected'); }, + deleteKvBulk: async (_account: string, namespace: string, keys: ReadonlyArray) => { + for (const key of keys) { + kv.delete(`${namespace}/${key}`); + } + }, deleteKvValue: async (_account: string, namespace: string, key: string) => { kv.delete(`${namespace}/${key}`); }, @@ -265,12 +270,25 @@ async function createFakeCloudflare() { origin: hyperdriveOriginFromConnectionString(DATABASE_URL), }), getWorkerPlainTextBindings: plainTextBindings, + listKvKeys: async (_account: string, namespace: string, prefix: string) => + [...kv.keys()] + .filter((key) => key.startsWith(`${namespace}/${prefix}`)) + .map((key) => ({ name: key.slice(namespace.length + 1) })), listWorkerDomains: async () => [], listWorkerNames: async () => [...workers.keys()], listWorkerRoutes: async () => [], listWorkerServiceRoutes: async () => [], publishWorkerCustomDomainsSafely: async () => {}, publishWorkerRoutesSafely: async () => {}, + putKvBulk: async ( + _account: string, + namespace: string, + entries: ReadonlyArray<{ key: string; value: string }>, + ) => { + for (const entry of entries) { + kv.set(`${namespace}/${entry.key}`, entry.value); + } + }, putKvValue: async (_account: string, namespace: string, key: string, value: string) => { kv.set(`${namespace}/${key}`, value); }, diff --git a/packages/void/test/unit/platform-installer.test.ts b/packages/void/test/unit/platform-installer.test.ts index 02c468ef2..a49ae3907 100644 --- a/packages/void/test/unit/platform-installer.test.ts +++ b/packages/void/test/unit/platform-installer.test.ts @@ -29,6 +29,7 @@ import { } from '@void/platform/installer'; import { toSlash } from 'pathslash'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { AiLoggingProjectionKv } from '../../src/cli/platform-ai-logging-projection.ts'; import { PlatformCloudflareApiError } from '../../src/cli/platform-cloudflare.ts'; import { hyperdriveOriginConfigHash, @@ -110,16 +111,19 @@ type InstallArguments = Parameters; type InstallClient = InstallArguments[3]['client']; type DefaultedClientMethods = | 'apiBaseUrl' + | 'deleteKvBulk' | 'deleteKvValue' | 'disableHyperdriveCaching' | 'getHyperdriveConfig' | 'getWorkerPlainTextBindings' + | 'listKvKeys' | 'listWorkerDomains' | 'listWorkerNames' | 'listWorkerRoutes' | 'listWorkerServiceRoutes' | 'publishWorkerCustomDomainsSafely' | 'publishWorkerRoutesSafely' + | 'putKvBulk' | 'request'; type TestInstallerDependencies = Omit & { client: Omit & @@ -144,8 +148,10 @@ function installPlatform( ...dependencies, client: { apiBaseUrl: resolvePinnedApiBaseUrl(), + deleteKvBulk: unexpectedCloudflareCall('deleteKvBulk'), deleteKvValue: async () => {}, getWorkerPlainTextBindings: unexpectedCloudflareCall('getWorkerPlainTextBindings'), + listKvKeys: unexpectedCloudflareCall('listKvKeys'), listWorkerDomains: unexpectedCloudflareCall('listWorkerDomains'), listWorkerNames: unexpectedCloudflareCall('listWorkerNames'), listWorkerRoutes: unexpectedCloudflareCall('listWorkerRoutes'), @@ -154,6 +160,7 @@ function installPlatform( 'publishWorkerCustomDomainsSafely', ), publishWorkerRoutesSafely: unexpectedCloudflareCall('publishWorkerRoutesSafely'), + putKvBulk: unexpectedCloudflareCall('putKvBulk'), request: unexpectedCloudflareCall('request'), ...dependencies.client, getHyperdriveConfig: @@ -837,6 +844,106 @@ describe('platform installer', () => { expect(events).toEqual(['lift fence', 'enable traffic']); }); + it('publishes AI logging settings after migrations, before the Workers upload, and again after it', async () => { + useRegistryDirectory(); + const plan = await createPlatformInstallPlan( + planningClient(), + { + accountId: 'account', + applicationDomain: '', + zoneName: '', + workersDev: true, + controlPlaneDomain: '', + dedicatedZone: false, + displayName: 'Logging', + installationSlug: 'logging', + }, + null, + ); + let index = 0; + const create = async (_account: string, name: string) => ({ + id: `resource-${index++}`, + name, + }); + const events: Array = []; + const kvCalls: Array = []; + const result = await installPlatform(plan, freshSecrets(), null, { + claimDatabase, + acquireDatabaseLifecycleLock: async () => + Object.assign(async () => {}, { + prepareSandboxDrain: async () => sandboxDrainPreparation('complete'), + publishAiLogging: async (kv: AiLoggingProjectionKv) => { + events.push('publish AI logging'); + await kv.list('ai-logging:'); + await kv.put([ + { key: 'ai-logging:p1', value: 'off', metadata: { mode: 'off', at: 1 } }, + ]); + await kv.delete(['ai-logging:p2']); + return { skipped: false as const, projects: 2, written: 1, deleted: 1 }; + }, + }), + client: { + createAiGateway: create, + createDnsRecord: async () => { + throw new Error('no DNS expected'); + }, + createZone: async () => { + throw new Error('no zone expected'); + }, + createKvNamespace: create, + createR2Bucket: create, + createQueue: create, + createDispatchNamespace: create, + createHyperdrive: create, + putKvValue: async () => {}, + putR2Object: async () => {}, + listKvKeys: async (...args) => { + kvCalls.push(['list', ...args]); + return []; + }, + putKvBulk: async (...args) => { + kvCalls.push(['put', ...args]); + }, + deleteKvBulk: async (...args) => { + kvCalls.push(['delete', ...args]); + }, + }, + deployWorker: async (config) => { + events.push(`deploy ${String(config.name)}`); + }, + prepareDatabase: async () => { + events.push('migrate'); + return prepareDatabase(); + }, + revalidateBeforeWorkerDeploy: async () => {}, + verifyHealth: async () => {}, + }); + expect(result.manifest.status).toBe('installed'); + const upload = ['api', 'proxy', 'email-gateway', 'tail', 'dispatch'].map( + (name) => `deploy void-logging-${name}`, + ); + // A fresh install uploads the Workers with traffic paused, then deploys them + // again to open traffic; the second pass runs between the two. + expect(events).toEqual([ + 'migrate', + 'publish AI logging', + ...upload, + 'publish AI logging', + ...upload, + ]); + const routing = result.manifest.resources.find((resource) => resource.key === 'routing-kv')!; + expect(kvCalls.slice(0, 3)).toEqual([ + ['list', 'account', routing.id, 'ai-logging:'], + [ + 'put', + 'account', + routing.id, + [{ key: 'ai-logging:p1', value: 'off', metadata: { mode: 'off', at: 1 } }], + ], + ['delete', 'account', routing.id, ['ai-logging:p2']], + ]); + }); + it('keeps traffic disabled and the verified Worker set when shared email bootstrap blocks', async () => { const directory = useRegistryDirectory(); const plan = await createPlatformInstallPlan( @@ -3558,6 +3665,16 @@ describe('platform installer', () => { expect( queries.find(({ sql }) => sql.startsWith('DELETE FROM "platform_settings"'))?.values, ).toEqual(['platform.uninstall']); + + // AI logging projection runs on this same session; this fake has no table. + await expect( + lock.publishAiLogging!({ + list: async () => [], + put: async () => {}, + delete: async () => {}, + }), + ).resolves.toEqual({ skipped: true }); + expect(queries.at(-1)?.sql).toContain("to_regclass('public.project_ai_logging')"); await lock(); }); diff --git a/packages/void/test/unit/platform-management-upgrade.test.ts b/packages/void/test/unit/platform-management-upgrade.test.ts index 36806cc34..0fac55263 100644 --- a/packages/void/test/unit/platform-management-upgrade.test.ts +++ b/packages/void/test/unit/platform-management-upgrade.test.ts @@ -33,6 +33,7 @@ const state = vi.hoisted(() => ({ events: [] as Array, manifest: undefined as PlatformInstallationManifest | undefined, prepareDatabase: vi.fn(), + publishAiLogging: vi.fn(), readAdministratorSetup: vi.fn(), readAuthenticationState: vi.fn(), readPlanZeroTrust: vi.fn(), @@ -145,6 +146,7 @@ vi.mock('../../src/cli/platform-installer.ts', async (importOriginal) => { authoritativeManifest: state.manifest, checkpoint: vi.fn(), prepareDatabase: state.prepareDatabase, + publishAiLogging: state.publishAiLogging, readAdministratorSetup: state.readAdministratorSetup, readAuthenticationState: state.readAuthenticationState, readProjectZeroTrust: state.readProjectZeroTrust, @@ -164,6 +166,8 @@ vi.mock('../../src/cli/platform-installer.ts', async (importOriginal) => { existing!.ownershipNonce, ); expect(borrowed.prepareDatabase).toBe(state.prepareDatabase); + // Every upgrade and repair republishes AI logging settings on the held connection. + expect(borrowed.publishAiLogging).toBe(state.publishAiLogging); expect(borrowed.readAuthenticationState).toBe(state.readAuthenticationState); await borrowed.readAuthenticationState!(); state.events.push('installer-start'); diff --git a/packages/void/test/unit/platform-plans-config.test.ts b/packages/void/test/unit/platform-plans-config.test.ts index 0e993fc4c..0d071029a 100644 --- a/packages/void/test/unit/platform-plans-config.test.ts +++ b/packages/void/test/unit/platform-plans-config.test.ts @@ -357,3 +357,228 @@ describe('platform config plans', () => { ); }); }); + +describe('platform config plans AI logging defaults', () => { + const catalog = { ...createDefaultPlanCatalog('free'), revision: 7 }; + const aiLogging = { modes: { pro: 'metadata' }, publish: null, remaining: 0 }; + + /** A platform that answers each request from `answer`, recording request bodies. */ + function platform(answer: (url: string, body?: Record) => unknown) { + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = input.toString(); + const body = init?.body ? JSON.parse(init.body as string) : undefined; + calls.push({ url, body }); + return Response.json(answer(url, body)); + }); + vi.stubGlobal('fetch', fetcher); + return fetcher; + } + + function output() { + return vi + .mocked(console.log) + .mock.calls.map((call) => String(call[0])) + .join('\n'); + } + + async function planFile(content: unknown) { + const file = join(directory, 'plan.json'); + await writeFile(file, JSON.stringify(content)); + return file; + } + + it('refuses aiLogging before any request on a platform without plan defaults', async () => { + platform(() => ({ catalog, accounts: [] })); + const file = await planFile({ aiLogging: 'off' }); + await expect( + runPlatformPlansConfig( + parsePlatformPlansArgs(['plans', 'set', 'pro', '--file', file, '--yes']), + ), + ).rejects.toThrow('Upgrade the platform first'); + expect(calls.filter((call) => call.body)).toHaveLength(0); + }); + + it('rejects an unknown aiLogging mode in a plan file', async () => { + platform(() => ({ catalog, accounts: [], aiLogging })); + const file = await planFile({ aiLogging: 'everything' }); + await expect( + runPlatformPlansConfig( + parsePlatformPlansArgs(['plans', 'set', 'pro', '--file', file, '--yes']), + ), + ).rejects.toThrow('aiLogging must be one of inherit, off, metadata, full'); + expect(calls.filter((call) => call.body)).toHaveLength(0); + }); + + it('previews the AI Gateway logging change as a diff line with the project count', async () => { + platform((_url, body) => + body + ? { + dryRun: true, + action: 'plans.set', + target: 'pro', + expectedRevision: 7, + catalog: { ...catalog, revision: 8 }, + affectedAccounts: 3, + aiLogging: { modes: {}, planIds: ['pro'], includeUnknown: false }, + aiLoggingPublish: { complete: false, processed: 0, remaining: 12, expectedBy: null }, + } + : { catalog, accounts: [], aiLogging }, + ); + const file = await planFile({ aiLogging: 'inherit' }); + await runPlatformPlansConfig( + parsePlatformPlansArgs(['plans', 'set', 'pro', '--file', file, '--plan']), + ); + expect(calls.at(-1)?.body).toEqual({ aiLogging: 'inherit', dryRun: true }); + expect(output()).toContain('AI Gateway logging: metadata → inherit'); + expect(output()).toContain('AI logging settings to publish: 12 projects'); + }); + + it('shows the current default the dry run read, not the cached listing', async () => { + platform((_url, body) => + body + ? { + dryRun: true, + action: 'plans.set', + target: 'pro', + expectedRevision: 7, + catalog: { ...catalog, revision: 8 }, + affectedAccounts: 3, + aiLogging: { + modes: {}, + modesBefore: { pro: 'off' }, + planIds: ['pro'], + includeUnknown: false, + }, + } + : { catalog, accounts: [], aiLogging }, + ); + const file = await planFile({ aiLogging: 'inherit' }); + await runPlatformPlansConfig( + parsePlatformPlansArgs(['plans', 'set', 'pro', '--file', file, '--plan']), + ); + expect(output()).toContain('AI Gateway logging: off → inherit'); + }); + + it('offers the setting in the interactive picker', async () => { + vi.stubGlobal('process', { ...process, stdin: { ...process.stdin, isTTY: true } }); + platform((_url, body) => + body?.dryRun + ? { dryRun: true, expectedRevision: 7, catalog, affectedAccounts: 0, aiLogging } + : body + ? { + catalog, + reconciliation: { complete: true, processed: 0 }, + aiLoggingPublish: { complete: true, processed: 0, remaining: 0, expectedBy: null }, + } + : { catalog, accounts: [], aiLogging }, + ); + vi.mocked(select).mockResolvedValueOnce('aiLogging').mockResolvedValueOnce('off'); + vi.mocked(confirm).mockResolvedValueOnce(true); + await runPlatformPlansConfig(parsePlatformPlansArgs(['plans', 'set', 'pro'])); + expect(select).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + options: expect.arrayContaining([ + { value: 'aiLogging', label: 'AI Gateway logging: metadata' }, + ]), + }), + ); + expect(select).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ initialValue: 'metadata' }), + ); + expect(calls.at(-1)?.body).toEqual({ aiLogging: 'off', expectedRevision: 7 }); + }); + + it('keeps syncing until the AI logging publish completes, then prints the expected time', async () => { + const pages = [ + { complete: false, processed: 100, remaining: 150, expectedBy: '2026-11-13T14:01:00.000Z' }, + { complete: false, processed: 100, remaining: 50, expectedBy: '2026-11-13T14:02:00.000Z' }, + { complete: true, processed: 50, remaining: 0, expectedBy: '2026-11-13T14:03:20.000Z' }, + ]; + platform((url, body) => { + if (!body) { + return { catalog, accounts: [], aiLogging }; + } + if (body.dryRun) { + return { + dryRun: true, + action: 'plans.set', + target: 'pro', + expectedRevision: 7, + catalog: { ...catalog, revision: 8 }, + affectedAccounts: 0, + aiLogging: { modes: { pro: 'off' }, planIds: ['pro'], includeUnknown: false }, + }; + } + return { + ...(url.endsWith('/sync') ? {} : { action: 'plans.set', target: 'pro' }), + catalog: { ...catalog, revision: 8 }, + reconciliation: { complete: true, processed: 0 }, + aiLoggingPublish: pages.shift(), + }; + }); + const file = await planFile({ aiLogging: 'off' }); + await runPlatformPlansConfig( + parsePlatformPlansArgs(['plans', 'set', 'pro', '--file', file, '--yes']), + ); + expect(calls.filter((call) => call.url.endsWith('/sync'))).toEqual([ + { + url: 'https://api.example.com/operator/v1/config/plans/sync', + body: { expectedRevision: 8 }, + }, + { + url: 'https://api.example.com/operator/v1/config/plans/sync', + body: { expectedRevision: 8 }, + }, + ]); + expect(output()).toContain( + 'AI logging published to 250 projects; expected by 14:03:20 UTC (normally within 2 minutes).', + ); + }); + + it('stops with a resumption command when the AI logging publish fails', async () => { + platform((_url, body) => + !body + ? { catalog, accounts: [], aiLogging } + : body.dryRun + ? { dryRun: true, expectedRevision: 7, catalog, affectedAccounts: 0 } + : { + action: 'plans.set', + target: 'pro', + catalog: { ...catalog, revision: 8 }, + reconciliation: { complete: true, processed: 0 }, + aiLoggingPublish: { complete: false, processed: 0, remaining: 3, error: 'KV failed' }, + // The API marks a failed publish page this way; the CLI must still name plans sync. + ok: false, + }, + ); + const file = await planFile({ aiLogging: 'off' }); + await expect( + runPlatformPlansConfig( + parsePlatformPlansArgs(['plans', 'set', 'pro', '--file', file, '--yes']), + ), + ).rejects.toMatchObject({ + code: 'plan_sync_pending', + message: expect.stringContaining('AI logging setting is not published to every project'), + }); + }); + + it('shows the plan default and a pending publish', async () => { + platform((url) => + url.endsWith('/config/plans') + ? { catalog, accounts: [], aiLogging } + : { + catalogRevision: 7, + default: false, + plan: catalog.plans.find((plan) => plan.id === 'pro'), + accounts: 2, + aiLogging: 'metadata', + aiLoggingPublish: { complete: false, remaining: 4, planIds: ['pro'] }, + }, + ); + await runPlatformPlansConfig(parsePlatformPlansArgs(['plans', 'show', 'pro'])); + expect(output()).toContain('AI Gateway logging: metadata'); + expect(output()).toContain('AI logging publish pending: 4 projects remaining'); + }); +}); diff --git a/platform/meta/design-docs/0019-workers-ai.md b/platform/meta/design-docs/0019-workers-ai.md index 89b416ffe..959733510 100644 --- a/platform/meta/design-docs/0019-workers-ai.md +++ b/platform/meta/design-docs/0019-workers-ai.md @@ -7,4 +7,4 @@ status: implemented Importing void/ai enables Void's typed AI runtime and inferred binding. ai.run() follows Cloudflare's model and input types; ai.stream() wraps streaming inference in an SSE response. Direct Cloudflare deployments use the app owner's AI binding and allowance. Platform deployments route inference through the platform proxy so tenant Workers do not receive the platform account's AI binding directly. -The proxy applies the installation's project identity and usage controls before forwarding to Cloudflare AI Gateway. The same proxy also serves other remote bindings, so AI does not have its own Worker. See the [AI runtime](../../../packages/void/src/runtime/ai.ts), [proxy route](../../../platform/packages/proxy/src/ai.ts), and [AI guide](../../../docs/guide/ai.md). +The proxy applies the installation's project identity, usage controls and [AI Gateway logging policy](./0094-ai-gateway-logging-policy.md) before forwarding to Cloudflare AI Gateway. The same proxy also serves other remote bindings, so AI does not have its own Worker. See the [AI runtime](../../../packages/void/src/runtime/ai.ts), [proxy route](../../../platform/packages/proxy/src/ai.ts), and [AI guide](../../../docs/guide/ai.md). diff --git a/platform/meta/design-docs/0049-multi-provider-ai.md b/platform/meta/design-docs/0049-multi-provider-ai.md index 361038cdf..b767775df 100644 --- a/platform/meta/design-docs/0049-multi-provider-ai.md +++ b/platform/meta/design-docs/0049-multi-provider-ai.md @@ -7,4 +7,4 @@ status: implemented Void exposes two third-party model paths through void/ai. ai.run() accepts Cloudflare AI Gateway model identifiers and Cloudflare-compatible input schemas. ai.provider(provider).fetch(path, init) sends a provider-native request through AI Gateway, using the app's provider key from a project secret. The latter preserves the provider's native request and response shape. -On a direct deployment, requests use the app's configured AI Gateway. On a platform deployment, requests pass through the installation proxy and its project controls. Neither path grants a tenant the platform's AI credential. See the [AI runtime](../../../packages/void/src/runtime/ai.ts), [proxy route](../../../platform/packages/proxy/src/ai.ts), and [AI guide](../../../docs/guide/ai.md). +On a direct deployment, requests use the app's configured AI Gateway. On a platform deployment, requests pass through the installation proxy and its project controls, including the [AI Gateway logging policy](./0094-ai-gateway-logging-policy.md), which removes any logging headers the app sends. Neither path grants a tenant the platform's AI credential. See the [AI runtime](../../../packages/void/src/runtime/ai.ts), [proxy route](../../../platform/packages/proxy/src/ai.ts), and [AI guide](../../../docs/guide/ai.md). diff --git a/platform/meta/design-docs/0094-ai-gateway-logging-policy.md b/platform/meta/design-docs/0094-ai-gateway-logging-policy.md new file mode 100644 index 000000000..15877258e --- /dev/null +++ b/platform/meta/design-docs/0094-ai-gateway-logging-policy.md @@ -0,0 +1,380 @@ +--- +title: AI Gateway logging policy +status: implemented +created: 2026-11-13 +--- + +# AI Gateway logging policy + +Related: [#793](https://github.com/voidzero-dev/void/issues/793). User docs: +[AI Gateway logging](../../../docs/reference/cli/platform-users.md#ai-gateway-logging) and +[plan defaults](../../../docs/guide/platform/administration/plans.md#ai-gateway-logging). +Background: [Workers AI](./0019-workers-ai.md), [multi-provider AI](./0049-multi-provider-ai.md), +[installation plans](./0090-installation-plans.md). + +## Contract + +The platform, not the app, chooses how AI Gateway logs a project's AI requests: +`inherit`, `off`, `metadata` or `full`. The effective mode is the project's own +setting, else the owner's plan default, else `inherit`. A project setting wins in +both directions; only administrators set either. An account whose plan ID is not +in the catalog takes the catalog **default plan's** logging (limits keep their +older fallback to `free`), because an operator who sets `off` on every plan expects +every account to be off. + +`inherit` sends no logging option, so the gateway's own `collect_logs` applies. The +installer creates gateways with logging on, and the platform never reads the value. + +The proxy applies the mode to Workers AI `run`, `toMarkdown` and provider-native +calls, from deployed Workers and from `void dev` of a linked project. It still +strips every caller-supplied `collectLog`, `collectLogPayload` and `cf-aig-*` key +first, including `sessionOptions.extraHeaders`. + +The written promise and its limits (requests already running, earlier logs, +Logpush, Unified Billing details, retention) are in the user docs. "Normally +within 2 minutes" is a target, not a guarantee: KV says a write can take up to +60 s, or the `cacheTtl`, to reach other locations. The readback `state` is the +authority. + +## Why a KV projection + +Release 1.8.0 read the policy from the API, and so from PostgreSQL, on every AI +call, and the API refused that read unless `PLATFORM_AUTH_DATABASE_UNCACHED=true`. +That flag is a self-hosted statement about the auth database path. Hosted Void +Cloud never sets it, so every hosted AI call returned 503. A setting that changes +rarely does not belong on the per-call path. + +The policy now reaches the proxy the same way the `aiNeuronsPerMonth` block does +(`ai-block:`): PostgreSQL is the source of truth and ROUTING KV holds a +projection. + +``` +operator / plan / account events per AI call (run, provider, toMarkdown) + PostgreSQL proxy + project_ai_logging (0064) ROUTING.get('ai-logging:', {cacheTtl: 30}) + platform_settings 'plans:ai-logging' null -> inherit + │ known mode -> apply + │ publishProjectAiLogging() KV error -> 503 ai_logging_policy_unavailable + │ (under the project row lock) other -> 503 ai_logging_policy_invalid + v ▲ + ROUTING KV ai-logging: ────────────────┘ no API call, no PostgreSQL, no flag + value 'off' | 'metadata' | 'full' + metadata { mode, at } + absent = inherit +``` + +## Data + +- `project_ai_logging` (migration 0064): one row per project override. No row + means the project follows its plan. +- `platform_settings` key `plans:ai-logging`: + `{ modes: { : mode }, publish: { revision, planIds, includeUnknown, cursor, complete, error? } | null }`. + Entries for plans no longer in the catalog are ignored. A missing row means every + plan inherits. +- KV `ai-logging:`: a bare mode string with metadata `{ mode, at }` + (`at` is the publish time in ms). A key exists only when the effective mode is not + `inherit`, so inherit projects need no backfill. A literal `inherit` value, or any + value the proxy does not know, fails closed: an older proxy refuses a newer mode + instead of treating it as inherit. + +Plan defaults live in their own settings key, not in `plans:catalog`. Older APIs +rebuild every plan from known keys and save that object on every catalog write, +including each `plans sync` step, so after a rollback one edit would erase every +plan default and loosen logging on re-upgrade. Plan limits must also be an exact +numeric key set, and the plan publish must not wait on the usage-counter Durable +Object calls that account reconciliation makes. + +Shared code lives in `@void/platform/ai-logging` +([source](../../../packages/platform/src/ai-logging.ts)), so the proxy, the API and +the installer in `packages/void` use one resolver, one key name and one parser +without importing `platform/`. + +## Request path + +The proxy reads the key only in `/ai/run`, `/ai/provider` and `/ai/toMarkdown`, +after body validation, so a 400 body, `/ai/models` and unknown `/ai/*` paths never +read it. The read is separate from the `ai-block` read, which keeps that shared +error path and `/ai/models` unchanged. `cacheTtl: 30` is the KV minimum; there is no +isolate cache on top. Failures log +`[ai] logging setting unavailable { projectId, reason, error | valueLength }`, +never the value. + +All mode-to-option mapping is in `BINDING_LOGGING` and `PROVIDER_LOGGING` in +[proxy/src/ai-logging.ts](../../packages/proxy/src/ai-logging.ts): + +| mode | binding (`run`, `toMarkdown`) | provider calls | +| -------- | --------------------------------------------------------------------- | ------------------------------------------- | +| inherit | nothing | nothing | +| off | `collectLog: false` | `cf-aig-collect-log: false` | +| metadata | `collectLog: true` + extra header `cf-aig-collect-log-payload: false` | `cf-aig-collect-log: true`, payload `false` | +| full | `collectLog: true` + payload `true` | `cf-aig-collect-log: true`, payload `true` | + +Cloudflare documents only `gateway.collectLog` for the binding. The binding +`metadata` row depends on live check A below. + +## Publishing + +`publishProjectAiLogging(tx, routing, projectId, …)` in +[api/src/ai-logging.ts](../../packages/api/src/ai-logging.ts) is the only API writer. + +**Rule 1: every KV write happens while the project row lock is held, and writes a +value read under that lock.** A later publisher waits for the lock, reads newer +inputs and writes last; KV keeps the last write. + +1. Lock the row on its own (`UPDATE projects SET id = id WHERE id = … RETURNING status`), + then read every input in a second write statement, which Hyperdrive never caches: + `UPDATE projects … FROM users … LEFT JOIN project_ai_logging, plans:catalog, plans:ai-logging … RETURNING`. + The order matters. Under READ COMMITTED a statement that waits for a row lock + re-checks only the locked row and keeps the joined rows from the snapshot it took + before the wait, so a publisher queued behind a newer change would write the old + value. The second statement never waits and gets a fresh snapshot. A test records + statements and fails if a plain SELECT feeds the publish; a PostgreSQL-only test + covers the wait. +2. Resolve in TypeScript. Skip projects whose status is `deleting`. +3. `put` with metadata, or `delete` for inherit. A KV 429 is retried under the lock + after 1 s, 2 s and 4 s; any other error, or a fourth 429, throws. + +The operator POST orders the write by direction, using effective values. +`publishOrder` knows only `off < metadata < full`; any change to or from `inherit` +is `unknown`, because inherit means the gateway setting. + +``` +tx1: lock row, read previous effective, save project_ai_logging + tighten | same -> KV write in tx1; KV fails -> rollback, 503 ai_logging_publish_failed + loosen | unknown -> commit tx1 + tx2: lock again, re-read, KV write + KV fails -> 503 ai_logging_publish_pending (row kept) +reply: mode, planId, planKnown, defaultPlanId, planMode, effective, source, previous, expectedBy = at + 120 s +``` + +So for a known order, KV is never looser than PostgreSQL. For changes with +`inherit`, every failure is an error to the operator and readback shows `drift`. +`same` still writes, so a retry after `ai_logging_publish_pending` repairs KV. +A tx1 rollback undoes only that call. A row saved by an earlier call that ended in +`ai_logging_publish_pending` stays, and the hourly sweep publishes it, so the +`ai_logging_publish_failed` message names the saved project mode and does not say +that nothing changed. The pending message says the sweep publishes the saved row +if the operator does not retry. + +| Event | Publish | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Project override | operator POST, ordered as above | +| Plan default edit or clear | plan route saves `plans:ai-logging` with a `publish` record, then runs one page | +| Signup default change | same, with `includeUnknown` | +| Plan removed with replacement | a page for the replacement plan's projects | +| User plan change | the user's active projects without an override, one page per call, after the plan update and before the usage-counter calls; an unfinished or failed publish is 500 `partial_failure` | +| Ownership transfer | one publish after the transfer commits | +| Project create | inside the create transaction when the plan is not inherit; a KV error fails the create. The transaction starts with the plan lock shared | +| Project delete | key removed after the project row is deleted; a failed delete is left to the sweep | +| Drift | hourly sweep and installer projection | + +Plan publish pages select active projects on the affected plans (or not in the +catalog, with `includeUnknown`) with no override, `id > cursor`, ordered by id, 100 +per page. The page and the user plan change list their projects through a no-op +`UPDATE … RETURNING id`, so a cached list cannot leave out a project created or +moved in the last minute. A user plan change does not compare the two plans first: +those reads could be cached, and a retry after a failed publish sees the new plan +on both sides, so every project without an override is published. One user plan +change call publishes at most one page (100 projects), so a large account stays +inside the 1000 KV operations allowed per invocation. Its cursor is saved in +`platform_settings` under `ai-logging:user-publish:` with the plan it is +for. Running the same plan change again continues from that cursor; a change to a +different plan starts from the first project, because projects before the cursor +were published for another plan. The row also holds a run ID, new for each change +that does not resume. A page saves progress only while the run ID still matches, so +after B -> C -> B a page left over from the first B change cannot move the new +run's cursor or remove its row. A page for a plan the account has since left +does nothing. The row is removed after the last page. The usage-counter calls run on +every call, whether or not the publish finished. A project create +holds the plan lock shared from its first statement, so a plan change or plan default +edit either waits for the create to commit and then lists the new project, or the +create reads the change. The dry run of a plan change reports `aiLoggingPublish` +with the number of projects it would publish. Each project runs the helper in its own short transaction; no +usage-counter call. The cursor is saved under the plan lock only when the record's +`revision` still matches and the cursor moves forward. The record's `revision` is the +new catalog revision, and the plan change reads the catalog it builds on through +the same no-op write as the defaults, so two changes never share a revision even +when Hyperdrive caches the plain catalog read. A newer plan change folds +into a running publish (plan IDs joined, `includeUnknown` ORed, cursor reset). On a +page failure the cursor stops at the last project written and `publish.error` is +set. `plans sync`, the dashboard and the hourly sweep each run one more page; the +CLI repeats `sync` until both account updates and the publish report complete. +A route answers `ok: false` when the catalog saved but a page failed; the CLI keeps +that body and stops with `plan_sync_pending` and the `plans sync --yes` hint rather +than a generic `partial_failure`. + +The dry run returns `aiLogging.modesBefore`, the defaults it read under the plan +lock. The dashboard review and the CLI preview show those as the current values, +because the page and the listing read a row Hyperdrive may cache. The dashboard +edit form sends `aiLogging` only when the selector differs from the value the page +loaded, so an untouched selector on a stale page cannot clear a newer default. The +dashboard progress page counts accounts and projects separately. + +The hourly sweep ([api/src/ai-logging-sweep.ts](../../packages/api/src/ai-logging-sweep.ts)) +lists `ai-logging:` keys with metadata, compares them with the expected effective +modes, repairs mismatches through the helper, runs one page of an unfinished plan +publish, and logs `{ listed, expected, mismatched, repaired }`. A mismatch is a +key without metadata, a different mode, a missing key, or a key for a project that +is gone or now inherit. Keys for missing projects are removed only when they have no +metadata or are at least 10 minutes old, because a create writes its key before +commit. Keys of projects being deleted are left alone. The statuses of keys with no +expected mode are read 1000 IDs per query, inside the Postgres bind limit. The +sweep is the only place a KV read decides "no write": a stale list can only hide a +write the helper made under the lock in the last minute. + +One run uses at most 400 KV operations (`AI_LOGGING_SWEEP_KV_BUDGET`), because a +Worker invocation may make 1000 and the other hourly jobs share them. List pages +count first, and 200 stay free for the plan publish page when one is running. The +rest goes to repairs: setting a key costs one write, removing one costs two (its +cleared marker, then the delete), and keys of missing projects are deleted with +what is left. With no plan page and one list page, a run fixes between 199 and 399 +mismatches, so M mismatches are fixed within `ceil(M / 199)` hours at worst, plus +about 2 minutes to spread. + +API log lines: `[ai-logging] published { projectId, mode, source, previous, trigger }` +and `[ai-logging] publish failed { projectId, step, status, trigger }`. The audit +target for the project route is unchanged; `config/plans` audit targets record +`aiLogging`. + +## Readback + +`project show`, `GET …/ai-logging` and the admin project page return `aiLogging` +with the saved `mode` (unchanged since 1.8.0) plus `planId`, `planKnown`, +`defaultPlanId`, `planMode`, `effective`, `source`, `published`, `publishedAt` and +`state`: + +| state | when | +| ---------------------- | ------------------------------------------------------------------------------------------------------------ | +| `applied` | published matches effective and `at + 120 s` has passed, or both are inherit | +| `pending` | published matches effective and `at + 120 s` has not passed | +| `pending_plan_publish` | no match, the project has no override, and a plan publish or the owner's plan change publish is not complete | +| `drift` | no match otherwise (also an unknown value, or metadata missing or not matching the value) | +| `unknown` | the KV read failed | + +The key is read without `cacheTtl`, so the API sees its own writes at once. So the +saved inputs must be current too: a cached Hyperdrive row (up to a minute old) next +to a fresh key would show a false `drift` right after a change. Readback reads its +inputs through the same no-op write statements as the helper, in a transaction that +holds the project lock while it reads the key, so no publish can land between the +two reads. + +## Installer projection + +[platform-ai-logging-projection.ts](../../../packages/void/src/cli/platform-ai-logging-projection.ts) +runs on every install, upgrade and repair, twice: after the database schema is +prepared and before the Worker upload (the new proxy may go live before the new +API), and after the upload (to catch writes the old API made to PostgreSQL only). +It uses the installer's direct database connection, skips when +`project_ai_logging` does not exist, lists existing keys, then in one transaction +locks the candidate project rows `FOR NO KEY UPDATE` in id order (the lock an API +`UPDATE` takes, so it does not block new rows that reference a project), reads the inputs again in +a new statement, and writes with `putKvBulk` / `deleteKvBulk`. A KV failure rolls +back and fails the command. Key and marker `at` values come from the database +clock (`clock_timestamp()`), not the operator's machine, because readback and the +sweep compare them with the API's clock. The same 10-minute orphan grace applies. Invalid saved +plan settings fail the command before any upload. + +## Rollout and compatibility + +- Hosted deploys the proxy first. The new proxy works against the old API because a + missing key is inherit, so hosted AI recovers as soon as the proxy is live. +- Self-hosted uploads may make either Worker live first. A 1.8.0 proxy calls + `GET /internal/ai-logging/`. That route is kept: it reads the published KV + value and returns `{ mode }` with no flag. Remove it only when no supported upgrade + source and no hosted rollback target is 1.8.x. +- Self-hosted rollback to 1.8.0: its proxy reads overrides through its own API. + Plan defaults are not applied while rolled back, but stay saved and the next + upgrade publishes them again. +- Never roll the hosted API back to 1.8.x: its route returns 503 without the flag. + Rolling only the proxy back works against the new API. +- No new binding, variable, secret or Hyperdrive change. Native + `void deploy --platform cloudflare` is untouched. + +## Known limits + +- An unpublished change keeps the previous behavior. Failing closed on a missing + key would need a key for every project and turn any gap into an AI outage. +- On project delete, the policy key stays until the project row is gone, so a token + that still passes the owner check keeps the project's setting while teardown runs + or waits for a retry. Once the row is gone, human tokens fail the live owner check. + A project token stops working once proxies see the closed token floor, which + teardown writes before the row is deleted; if a proxy has not seen it yet when the + key is removed, that token can run as inherit for the rest of the floor's cache + time. A key left by a failed delete is removed by the next hourly sweep. +- Every projection run rewrites non-inherit keys with a new `at`, so readback shows + `pending` for about 2 minutes after each install or upgrade. + +## Live checks A and B + +Status: **pending**. Run them by hand on a scratch Cloudflare account before the +release that ships the promise, then record the results here. + +- A: binding `run` and `toMarkdown` with `collectLog: true` and + `extraHeaders['cf-aig-collect-log-payload'] = 'false'` store a log entry without + bodies. +- B: a binding call with `collectLog: false` still appears in + `aiGatewayRequestsAdaptiveGroups` with its metadata and cost, which metering reads. +- Provider calls through the gateway are also checked for each mode: `full` has a + body, `metadata` an entry without body, `off` no entry. + +Use a scratch gateway with logging turned on, and a token with Workers Scripts +Edit, Workers AI Read, AI Gateway Read and Account Analytics Read: + +```sh +CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... AI_LOGGING_CHECK_GATEWAY=... \ + node platform/scripts/ai-logging-live-check.ts +``` + +The [script](../../scripts/ai-logging-live-check.ts) uploads a temporary Worker, +calls each mode, polls gateway logs (up to 5 minutes) and analytics (up to 15 +minutes), deletes the Worker, and prints one line per call plus the A and B +results. It exits nonzero on any failure. + +| A | B | Binding `metadata` | Follow-up | +| ---- | ---- | ------------------------------------------------- | ------------------------------------------------------------------------------- | +| pass | pass | keep the current mapping | none | +| pass | fail | keep | `off` escapes `aiNeuronsPerMonth`; decide whether to refuse it on metered plans | +| fail | pass | change to `{ collectLog: false, payload: false }` | docs: Workers AI and Markdown calls in `metadata` are not logged | +| fail | fail | change as above | `off` and `metadata` escape metering on Workers AI; decide as above | + +Results: not run yet. + +## Decisions + +- Plan defaults are offered on hosted too, with the same code. Built-in plans stay + inherit. +- Commands print the expected time and do not wait. A `--wait` option can come later. +- No installation-wide default flag; plan defaults cover it. +- The dashboard project page is read-only for the project setting; the CLI is the + edit path. Project owners do not see the mode. +- A missing key means inherit. +- A saved `plans:ai-logging` value this release cannot parse stops every AI logging + write (project create, plan and project changes, the sweep and the projection) + rather than guessing a mode. Plan commands read it first, so they cannot repair it; + the message points to upgrading to the release that saved it. A repair command is + not built. +- Refusing `off` or `metadata` on metered plans is not built; it depends on check B. + +## Rejected alternatives + +| Alternative | Why not | +| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Keep the per-call API read without the flag | Cached Hyperdrive reads can be stale; adds a hop to the API's region; all AI depends on API and DB | +| A second, cache-disabled Hyperdrive binding | New infrastructure on every install, still a per-call hop | +| Set `PLATFORM_AUTH_DATABASE_UNCACHED` on hosted | Not true for the hosted auth database, and it changes platform-access behavior | +| Policy in the tenant token, JWT or a Worker binding | The token covers only project and generation; a binding is app-readable and needs a re-PATCH | +| One gateway per mode | Splits the metering filter on gateway id | +| Durable Object lookup per call | KV is the recommended store for config read on every request | +| Fold into the `ai-block` read | Changes that shared error path and makes `/ai/models` read the policy | +| A publication table | The `UPDATE … RETURNING` read, row lock and KV metadata give the same safety | +| Plan default inside `plans:catalog` | Older APIs erase it on any catalog write after a rollback; its publish would wait on counter calls | +| Loosen KV write after commit, outside the lock | A later tighten can finish first, and the late loosen then wins | +| A fixed rank for `inherit` | Its meaning is the gateway setting, which operators change | +| Read the gateway's `collect_logs` at publish time | Extra API call and token scope per publish, and the value can change afterward | +| Fail closed on a missing key | Needs a key for every project; any gap becomes an AI outage | +| Skip a publish write when KV already matches | KV reads can be stale; only the sweep skips | +| One-time backfill, or projection only before the upload | Misses rollback then re-upgrade, and old-API writes during the upload | +| Delete the old internal route | A 1.8.x proxy left live would refuse every AI call | +| Refuse `metadata` if the binding ignores the payload header | Strands saved settings; mapping to `collectLog: false` is stricter | +| Block the command until the change has spread | Slow; the expected time plus readback state gives the same information | +| Per-project only | #793 asks for per project or per plan, managed like `aiNeuronsPerMonth` | diff --git a/platform/meta/design-docs/README.md b/platform/meta/design-docs/README.md index 6e9dc7534..4c62fb575 100644 --- a/platform/meta/design-docs/README.md +++ b/platform/meta/design-docs/README.md @@ -21,6 +21,7 @@ Public packages are under packages/ and platform implementations are under platf | Serving and routing | [Asset fallback](./0066-wfp-asset-r2-fallback.md), [direct R2 uploads](./0068-direct-r2-asset-upload.md), [scoped R2 migration](./0084-scoped-r2-asset-migration.md), [route-aware ISR](./0077-route-aware-isr-ttl.md), [Pages error boundaries](./0093-pages-error-boundaries.md) | | Custom domains | [HTTP certificate validation](./0073-custom-domain-http-dcv-migration.md) | | Administration | [Operator CLI](./0078-platform-operator-cli.md), [project teams and ownership transfer](./0080-project-teams.md), [project Zero Trust](./0083-project-zero-trust.md), [installation plans](./0090-installation-plans.md) | +| AI | [Workers AI](./0019-workers-ai.md), [multi-provider AI](./0049-multi-provider-ai.md), [AI Gateway logging policy](./0094-ai-gateway-logging-policy.md) | | Email | [Routing, recovery and delivery](./0078-email-cf-native.md) | | Sandboxes | [Managed Sandbox restoration](./0082-managed-sandbox-restoration.md) | diff --git a/platform/meta/operations.md b/platform/meta/operations.md index e176aa07d..8ed971da7 100644 --- a/platform/meta/operations.md +++ b/platform/meta/operations.md @@ -48,6 +48,18 @@ selected GitHub environment and the deployment owner's secret manager. Keep secret-manager locations, account access assignments and credential-issuance procedures in that owner's private runbook. +### AI Gateway logging release + +The release after 1.8.0 moves project AI logging settings to ROUTING KV; see [design doc 0094](./design-docs/0094-ai-gateway-logging-policy.md). Before its first production deploy, count the saved project settings with a read-only query: + +```sql +SELECT count(*) FROM project_ai_logging; +``` + +Expect 0: the 1.8.0 operator route always returned 503 on hosted. If it is not 0, list the rows with `SELECT project_id, mode FROM project_ai_logging ORDER BY project_id;` and, after the API deploy, run `void platform project ai-logging --yes` for each one so the setting is published. Hosted AI works again as soon as the new proxy is live, because a missing key means `inherit`. Also check the `collect_logs` setting of the hosted `void` gateway in Cloudflare: it decides what `inherit` means on Void Cloud. + +Never roll the hosted API back to 1.8.x. Its AI logging route returns 503 without `PLATFORM_AUTH_DATABASE_UNCACHED`, which hosted never sets, so every AI call through a 1.8.x proxy and every `project ai-logging` command would fail. `worker rollback-all` from the first release after 1.8.0 selects a 1.8.x API, so roll back single Workers instead. Rolling only the proxy back to 1.8.0 works against the newer API. + ## Hosted Worker Maintenance Void Cloud operators can inspect the platform's six Workers and their versions: diff --git a/platform/packages/api/package.json b/platform/packages/api/package.json index 41406bb76..94f526930 100644 --- a/platform/packages/api/package.json +++ b/platform/packages/api/package.json @@ -10,8 +10,7 @@ "./routing": "./src/routing.ts", "./isr-store": "./src/isr-store.ts", "./usage-auth": "./src/usage-auth.ts", - "./proxy-token": "./src/proxy-token.ts", - "./ai-logging-policy": "./src/ai-logging-policy.ts" + "./proxy-token": "./src/proxy-token.ts" }, "scripts": { "db:generate": "drizzle-kit generate", diff --git a/platform/packages/api/src/admin/fate/page-data.ts b/platform/packages/api/src/admin/fate/page-data.ts index 3fdf06dc1..f3637d6fd 100644 --- a/platform/packages/api/src/admin/fate/page-data.ts +++ b/platform/packages/api/src/admin/fate/page-data.ts @@ -27,7 +27,12 @@ import type { Db } from '../../db.ts'; import type { ApiEnv } from '../../env.ts'; import { OperatorError } from '../../operator/common.ts'; import { cliVersionStats } from '../../operator/system-operations.ts'; -import { readPlanCatalog, readPlanCatalogWithAccounts, planFromCatalog } from '../../plans.ts'; +import { + initialPlanDefaultFor, + readPlanCatalog, + readPlanCatalogWithAccounts, + planFromCatalog, +} from '../../plans.ts'; import { getPlatformApiUrl, platformConnectCommand } from '../../platform-identity.ts'; import { projectUrl, projectWithUrl } from '../../project-url.ts'; import { invitations, platformLoginTests } from '../../schema.ts'; @@ -147,11 +152,17 @@ export async function loadUserDetailPage(context: AdminContext, path: string) { const { id } = detailLocation(path, '/admin/users/'); const { getOperatorUser } = await import('../../operator/tenant-service.ts'); const detail = await getOperatorUser(context, id); - const catalog = await readPlanCatalog(context.get('db')); + const { readPlanAiLoggingSettings } = await import('../../ai-logging-plans.ts'); + const { planAiLogging } = await import('../../ai-logging-policy.ts'); + const [catalog, aiLoggingSettings] = await Promise.all([ + readPlanCatalog(context.get('db'), initialPlanDefaultFor(context.env)), + readPlanAiLoggingSettings(context.get('db')), + ]); return { id: path, ...detail, limits: planFromCatalog(catalog, detail.user.plan).limits, + aiLogging: planAiLogging(aiLoggingSettings, catalog, detail.user.plan), plans: catalog.plans .filter((plan) => !plan.archived || plan.id === detail.user.plan) .map((plan) => ({ id: plan.id, name: plan.name, archived: plan.archived })), @@ -198,7 +209,7 @@ export async function loadProjectDetailPage(context: AdminContext, path: string) const { getOperatorProject } = await import('../../operator/tenant-service.ts'); const db = context.get('db'); const [detail, members, invitations, zeroTrust] = await Promise.all([ - getOperatorProject(db, id), + getOperatorProject(db, context.env, id), getProjectMembers(db, id), getPendingProjectInvitations(db, id), loadProjectZeroTrust(db, id), @@ -497,13 +508,15 @@ export async function loadPlansPage(context: AdminContext, path: string) { if (url.pathname !== home && url.pathname !== home + '/new' && !match) { throw new OperatorError(400, 'Invalid plans page request.'); } - const data = await readPlanCatalogWithAccounts( - context.get('db'), - context.env.PLATFORM_INSTALLATION_ID ? 'custom' : 'free', - ); + const initialPlanDefault = initialPlanDefaultFor(context.env); + const { readPlanAiLoggingProgress } = await import('../../ai-logging-plans.ts'); + const [data, aiLogging] = await Promise.all([ + readPlanCatalogWithAccounts(context.get('db'), initialPlanDefault), + readPlanAiLoggingProgress(context.get('db'), initialPlanDefault), + ]); const selectedPlanId = match?.[1] ?? null; if (selectedPlanId && !data.catalog.plans.some((plan) => plan.id === selectedPlanId)) { throw new OperatorError(404, 'Plan not found.'); } - return { id: path, ...data, selectedPlanId }; + return { id: path, ...data, aiLogging, selectedPlanId }; } diff --git a/platform/packages/api/src/admin/fate/page-server.ts b/platform/packages/api/src/admin/fate/page-server.ts index abdd91eb5..e16aa421d 100644 --- a/platform/packages/api/src/admin/fate/page-server.ts +++ b/platform/packages/api/src/admin/fate/page-server.ts @@ -34,6 +34,7 @@ export const plansDataView = dataView('AdminPlans')({ id: true, catalog: true, accounts: true, + aiLogging: true, selectedPlanId: true, }); export type AdminPlans = Entity; @@ -84,6 +85,7 @@ export const deploymentsPageDataView = dataView('AdminDep }); export const userDetailDataView = dataView('AdminUserDetail')({ limits: true, + aiLogging: true, plans: true, id: true, user: true, @@ -103,6 +105,7 @@ export const projectDetailDataView = dataView('AdminProject domains: true, builds: true, zeroTrust: true, + aiLogging: true, cfAccountId: true, deployUrl: true, tz: true, diff --git a/platform/packages/api/src/admin/fate/views.ts b/platform/packages/api/src/admin/fate/views.ts index 9b580e1dc..4aeb6a500 100644 --- a/platform/packages/api/src/admin/fate/views.ts +++ b/platform/packages/api/src/admin/fate/views.ts @@ -121,6 +121,7 @@ export const deploymentsPageRequest = (path: string) => export const UserDetailView = view()({ limits: true, + aiLogging: true, plans: true, id: true, user: true, @@ -140,6 +141,7 @@ export const ProjectDetailView = view()({ domains: true, builds: true, zeroTrust: true, + aiLogging: true, cfAccountId: true, deployUrl: true, tz: true, @@ -320,6 +322,7 @@ export const PlansView = view()({ id: true, catalog: true, accounts: true, + aiLogging: true, selectedPlanId: true, }); export const plansRequest = (path: string) => diff --git a/platform/packages/api/src/admin/plans.ts b/platform/packages/api/src/admin/plans.ts index 046547d04..23c01e9b3 100644 --- a/platform/packages/api/src/admin/plans.ts +++ b/platform/packages/api/src/admin/plans.ts @@ -1,4 +1,5 @@ import type { PlatformPlanCatalog } from '@void/platform/plans'; +import type { PlanAiLoggingSettings } from '../ai-logging-policy.ts'; import type { PlanCatalogAction } from '../operator/plans.ts'; export type AdminPlanPreview = { @@ -8,16 +9,26 @@ export type AdminPlanPreview = { expectedRevision: number; before: PlatformPlanCatalog; after: PlatformPlanCatalog; + /** Plan AI logging defaults before and after the change. */ + aiLoggingBefore: PlanAiLoggingSettings['modes']; + aiLoggingAfter: PlanAiLoggingSettings['modes']; affectedAccounts: number; warnings: Array; overLimitAccounts: Array<{ id: string; login: string; reasons: Array }>; usageSampleSize: number; }; +/** Progress of account updates and the AI logging publish, combined. */ export type AdminPlanProgress = { revision: number; complete: boolean; + /** Accounts plus projects written by this step; zero means it made no progress. */ processed: number; + /** Accounts whose limits were updated by this step. */ + accounts: number; + /** Projects whose AI logging setting was published by this step. */ + projects: number; + /** Changes whenever either step moves, so the page can tell it is not stuck. */ cursor: string | null; error: string | null; }; diff --git a/platform/packages/api/src/admin/react/views/PlansPage.tsx b/platform/packages/api/src/admin/react/views/PlansPage.tsx index 9f4735110..4fd7f7af7 100644 --- a/platform/packages/api/src/admin/react/views/PlansPage.tsx +++ b/platform/packages/api/src/admin/react/views/PlansPage.tsx @@ -19,6 +19,7 @@ import { import { Link } from '@void/react'; import { useEffect, useState } from 'react'; import { useRequest, useView } from 'react-fate'; +import type { AiLoggingMode, PlanAiLoggingSettings } from '../../../ai-logging-policy.ts'; import { PlansView, plansRequest } from '../../fate/views.ts'; import type { AdminPlanPreview, AdminPlanProgress } from '../../plans.ts'; import type { AdminPageProps } from '../page-utils.ts'; @@ -65,6 +66,15 @@ function limit(value: number, key?: EditablePlanLimit) { : 'Unlimited' : value.toLocaleString('en-US'); } +const aiLoggingLabels: Record = { + inherit: 'Inherit (AI Gateway setting)', + off: 'Off', + metadata: 'Metadata only', + full: 'Full (prompts and responses)', +}; +function aiLoggingMode(modes: PlanAiLoggingSettings['modes'], planId: string): AiLoggingMode { + return Object.hasOwn(modes, planId) ? modes[planId]! : 'inherit'; +} function usePlans(path: string) { const { plans: ref } = useRequest(plansRequest(path), { mode: 'stale-while-revalidate' }); const data = useView(PlansView, ref); @@ -122,8 +132,9 @@ function PlanAction({ export function PlansPage({ path, platformName, username }: AdminPageProps) { usePlanConfirmation(path); - const { catalog, accounts } = usePlans(path); + const { catalog, accounts, aiLogging } = usePlans(path); const pending = catalog.reconciliation && !catalog.reconciliation.complete; + const publishPending = aiLogging.publish && !aiLogging.publish.complete; return ( @@ -153,6 +164,20 @@ export function PlansPage({ path, platformName, username }: AdminPageProps) { )} + {!pending && publishPending && ( + +

AI logging publish pending

+

+ {aiLogging.remaining} projects still use their previous AI Gateway logging setting. + Updates continue every hour, or continue them now. +

+ {aiLogging.publish?.error &&

{aiLogging.publish.error}

} +
+ +